@gotcos/glasses-server 6.91.0 → 6.92.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/.env.example +34 -1
- package/CHANGELOG.md +131 -2
- package/README.md +36 -2
- package/managed-runtime-contract.json +8 -1
- package/package.json +1 -1
- package/server/lib/claude-circuit.ts +8 -4
- package/server/lib/claude-text-runner.ts +113 -0
- package/server/lib/dictation-fillers-fixture-cases.ts +214 -0
- package/server/lib/dictation-fillers.ts +30 -0
- package/server/lib/dictation-meaning-guard.ts +162 -12
- package/server/lib/dictation-polish-pipeline.ts +90 -12
- package/server/lib/prompt-edit.ts +84 -3
- package/server/lib/token-audit.ts +6 -1
- package/server/routes/dictation-polish-settings.ts +8 -1
- package/server/routes/prompt-drafts.ts +3 -2
- package/server/routes/prompt-edit.ts +47 -5
- package/server/scripts/dictation-parity-6920-mutation-gate.mjs +229 -0
- package/server/scripts/generate-dictation-fillers-fixture.ts +18 -0
|
@@ -20,9 +20,22 @@
|
|
|
20
20
|
* still loses "uh"), and any quoted mention ('"um"').
|
|
21
21
|
* - "uh huh", "uh oh" and "mm hmm" written without a hyphen: answers, not hesitations.
|
|
22
22
|
* - "like", "you know", "I mean": filler only sometimes, so the model decides, not a regex.
|
|
23
|
+
* - 6.92.0: a token that touches a path, handle or code character is part of that token, not a
|
|
24
|
+
* hesitation: "src/um.txt", "~/uh", "@uh", "#um", "$um", "my_um", "um.py", "um:8080", "um/bin".
|
|
25
|
+
* Kept when the character right before it is one of / . @ _ : # $ (a leading - is already
|
|
26
|
+
* outside the token pattern), or the character right after it is one of / @ _ # $, or a . or :
|
|
27
|
+
* followed by a letter or digit. A sentence-final "um." or "um:" (a space or the end after it)
|
|
28
|
+
* still goes.
|
|
23
29
|
* A filler inside a longer quote ("um, no") is removed like any other.
|
|
24
30
|
* If nothing but fillers was said, the original text is returned unchanged.
|
|
31
|
+
*
|
|
32
|
+
* 6.92.0: the language gate lives in the caller, not here (`stripFillersIfEnglish`): "um" and "er"
|
|
33
|
+
* are words in German, Portuguese, Danish, Norwegian and Dutch ("um 5 Uhr", "um carro", "Er kommt
|
|
34
|
+
* morgen"). This function stays the pure token rules that the canonical fixture
|
|
35
|
+
* (__fixtures__/dictation-fillers.json, scope "both") pins for COS Control's port.
|
|
25
36
|
*/
|
|
37
|
+
import { fillerTextLooksEnglish } from './dictation-meaning-guard.js'
|
|
38
|
+
|
|
26
39
|
|
|
27
40
|
const FILLER = /(?<![\p{L}\p{N}'’-])(um+|uhm|uh+|erm?|ah+|hm+|mm+|mhm)(?![\p{L}\p{N}'’-])/giu
|
|
28
41
|
/** After a filler: a pause mark (comma, ellipsis, dash) with its spacing. */
|
|
@@ -38,6 +51,10 @@ const QUOTE_OPEN = /["“”'‘’]$/
|
|
|
38
51
|
const QUOTE_CLOSE = /^["“”'‘’]/
|
|
39
52
|
/** A pause mark right after the token: a Titlecase filler set off this way is a hesitation. */
|
|
40
53
|
const PAUSE_AFTER = /^[ \t]*(?:,|\.{2,}|…)/
|
|
54
|
+
/** 6.92.0: a path, handle or code character right before the token ("~/uh", "@uh", "#um", ".um"). */
|
|
55
|
+
const TOUCH_BEFORE = /[/.@_:#$]$/
|
|
56
|
+
/** 6.92.0: right after it: / @ _ # $ ("um/bin", "um_id"), or . or : with a letter or digit next ("um.py", "um:8080"). */
|
|
57
|
+
const TOUCH_AFTER = /^(?:[/@_#$]|[.:][\p{L}\p{N}])/u
|
|
41
58
|
|
|
42
59
|
function afterAbbreviation(before: string): boolean {
|
|
43
60
|
const trimmed = before.replace(/[ \t]+$/, '')
|
|
@@ -73,6 +90,9 @@ export function stripDisfluencies(text: string): string {
|
|
|
73
90
|
const atStart = sentenceStart(out + before)
|
|
74
91
|
const keep = (token.length > 1 && token === token.toUpperCase())
|
|
75
92
|
|| (/^mm+$/i.test(token) && /\d[ \t]*$/.test(out + before))
|
|
93
|
+
// 6.92.0: part of a path, handle or code token, never a hesitation.
|
|
94
|
+
|| TOUCH_BEFORE.test(out + before)
|
|
95
|
+
|| TOUCH_AFTER.test(after)
|
|
76
96
|
// A capital away from a sentence start is a name ("Ask Ah Kow"), unless a pause mark
|
|
77
97
|
// sets it off as a hesitation ("I think, Um, we should").
|
|
78
98
|
|| (!atStart && /^[A-Z]/.test(token) && !PAUSE_AFTER.test(after))
|
|
@@ -114,3 +134,13 @@ export function stripDisfluencies(text: string): string {
|
|
|
114
134
|
const result = (out + rest).replace(/^[ \t]*[,;][ \t]*/, '')
|
|
115
135
|
return result.trim() === '' ? text : result
|
|
116
136
|
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* 6.92.0: the server's outbound filler step. The token rules (stripDisfluencies) run only when the
|
|
140
|
+
* text is mostly English (dictation-meaning-guard.ts fillerTextLooksEnglish), so "Wir treffen uns um 5 Uhr",
|
|
141
|
+
* "Comprei um carro" and "Er kommt morgen." go out as said. COS Control gates its own port with
|
|
142
|
+
* its own language detector; that is why the fixture tags these rows scope "server".
|
|
143
|
+
*/
|
|
144
|
+
export function stripFillersIfEnglish(text: string): string {
|
|
145
|
+
return fillerTextLooksEnglish(text) ? stripDisfluencies(text) : text
|
|
146
|
+
}
|
|
@@ -11,9 +11,16 @@
|
|
|
11
11
|
* "cant"), all one NEG token; never, no, none, nothing, nobody, neither, nor, without, unless,
|
|
12
12
|
* except;
|
|
13
13
|
* - number: digits, number words, ordinals ("twenty fifth" is 25, like "25th"), %, $, a digit with a
|
|
14
|
-
* scale word ("2 million" is two million, like "two million"), clock times
|
|
15
|
-
*
|
|
16
|
-
*
|
|
14
|
+
* scale word ("2 million" is two million, like "two million"), and clock times. A decimal point
|
|
15
|
+
* splits a number on both sides ("one point five" and "1.5" are both 1 then 5);
|
|
16
|
+
* - times (6.91.1): every clock time, spoken or written, in the dictation and in the answer, is read
|
|
17
|
+
* as minutes since midnight: "ten thirty", "half past ten", "quarter to five", "ten o'clock",
|
|
18
|
+
* "10:30", "10:30 am", "ten thirty pm", "22:30", "twenty two thirty". Without am or pm an hour
|
|
19
|
+
* from 1 to 12 reads both ways, so "ten thirty pm" written "22:30" is kept and "ten thirty" written
|
|
20
|
+
* "11:30" is a changed number. am, pm and a trailing ":00" are format only: "at ten" written
|
|
21
|
+
* "at 10:00 am" is kept. A time is kept when the answer has the same time, when it is on the hour
|
|
22
|
+
* and the answer says just that hour (and the dictation said no am or pm), or when every part of
|
|
23
|
+
* it survives as a plain number ("nineteen fifty" written "1950");
|
|
17
24
|
* - date: a weekday or a month;
|
|
18
25
|
* - name: a mid-sentence capital, a dictionary term or a speaker name. A sentence-initial word and
|
|
19
26
|
* "I" are not names unless they are dictionary terms. Skipped for a dictation that is not English.
|
|
@@ -52,8 +59,10 @@ export function meaningGuardEnabled(env: NodeJS.ProcessEnv = process.env): boole
|
|
|
52
59
|
return !(raw === '0' || raw === 'false' || raw === 'off')
|
|
53
60
|
}
|
|
54
61
|
|
|
55
|
-
interface Tok { raw: string; low: string; sentenceStart: boolean; pause: boolean; listMarker: boolean }
|
|
56
|
-
interface Item { kind: MeaningGuardKind; key: string; pos: number; list?: boolean }
|
|
62
|
+
interface Tok { raw: string; low: string; sentenceStart: boolean; pause: boolean; listMarker: boolean; colonNext: boolean }
|
|
63
|
+
interface Item { kind: MeaningGuardKind; key: string; pos: number; list?: boolean; inTime?: boolean; time?: TimeSpan }
|
|
64
|
+
/** A clock time: the tokens it covers, its minutes since midnight (two readings without am/pm), its parts. */
|
|
65
|
+
interface TimeSpan { start: number; end: number; times: number[]; explicit: boolean; parts: string[] }
|
|
57
66
|
|
|
58
67
|
const ONES = new Map(['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve',
|
|
59
68
|
'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'].map((w, i) => [w, i]))
|
|
@@ -148,7 +157,8 @@ function tokenize(text: string): Tok[] {
|
|
|
148
157
|
const sentenceStart = before.length === 0 || /[.!?:\n•]$/.test(before) || /\n\s*(?:[-*•]|\d+[.)])?\s*$/.test(text.slice(0, m.index))
|
|
149
158
|
// "1." or "2)" at the start of a line: a list number.
|
|
150
159
|
const listMarker = /^\d+$/.test(m[0]) && /(?:^|\n)[ \t]*$/.test(text.slice(0, m.index)) && /^[.)]/.test(text.slice(m.index + m[0].length))
|
|
151
|
-
|
|
160
|
+
const colonNext = /^:\d/.test(text.slice(m.index + m[0].length))
|
|
161
|
+
out.push({ raw: m[0], low: m[0].toLowerCase(), sentenceStart, pause: /[,;.!?—–()]/.test(gap), listMarker, colonNext })
|
|
152
162
|
last = m.index + m[0].length
|
|
153
163
|
}
|
|
154
164
|
return out
|
|
@@ -230,6 +240,74 @@ function numberRun(tokens: Tok[], i: number): { value: number; end: number } | n
|
|
|
230
240
|
return { value: total + group, end: j - 1 }
|
|
231
241
|
}
|
|
232
242
|
|
|
243
|
+
const MERIDIEM = new Set(['am', 'pm'])
|
|
244
|
+
|
|
245
|
+
/** Minutes since midnight: one reading with am/pm or a 24-hour hour, both readings for 1 to 12 without. */
|
|
246
|
+
function clockMinutes(hour: number, minute: number, meridiem: string | undefined): { times: number[]; explicit: boolean } {
|
|
247
|
+
if (meridiem && hour >= 1 && hour <= 12) return { times: [((hour % 12) + (meridiem === 'pm' ? 12 : 0)) * 60 + minute], explicit: true }
|
|
248
|
+
if (hour >= 1 && hour <= 12) return { times: [(hour % 12) * 60 + minute, ((hour % 12) + 12) * 60 + minute], explicit: false }
|
|
249
|
+
return { times: [hour * 60 + minute], explicit: true }
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The readings of a bare hour ("ten" or "10") as a time on the hour. */
|
|
253
|
+
function hourReadings(n: number): number[] {
|
|
254
|
+
if (n >= 1 && n <= 12) return [(n % 12) * 60, ((n % 12) + 12) * 60]
|
|
255
|
+
return n === 0 || (n >= 13 && n <= 23) ? [n * 60] : []
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/** A spoken hour: one to nineteen, twenty, twenty one to twenty three. */
|
|
259
|
+
function spokenHour(tokens: Tok[], i: number): { value: number; end: number } | null {
|
|
260
|
+
const w = tokens[i]?.low ?? ''
|
|
261
|
+
const v = ONES.get(w)
|
|
262
|
+
if (v !== undefined) return v >= 1 ? { value: v, end: i } : null
|
|
263
|
+
if (w !== 'twenty') return null
|
|
264
|
+
const ones = ONES.get(tokens[i + 1]?.low ?? '')
|
|
265
|
+
return ones !== undefined && ones >= 1 && ones <= 3 ? { value: 20 + ones, end: i + 1 } : { value: 20, end: i }
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Spoken minutes after an hour: "oh five", ten to nineteen, twenty to fifty nine. */
|
|
269
|
+
function spokenMinutes(tokens: Tok[], i: number): { value: number; end: number } | null {
|
|
270
|
+
const w = tokens[i]?.low ?? ''
|
|
271
|
+
const next = ONES.get(tokens[i + 1]?.low ?? '')
|
|
272
|
+
if (w === 'oh' || w === 'o') return next !== undefined && next >= 1 && next <= 9 ? { value: next, end: i + 1 } : null
|
|
273
|
+
const v = ONES.get(w)
|
|
274
|
+
if (v !== undefined) return v >= 10 ? { value: v, end: i } : null
|
|
275
|
+
const tens = TENS.get(w)
|
|
276
|
+
if (tens === undefined || tens > 50) return null
|
|
277
|
+
return next !== undefined && next >= 1 && next <= 9 ? { value: tens + next, end: i + 1 } : { value: tens, end: i }
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** One clock time starting at token i, or null. An hour alone is not a time; it needs minutes or am/pm. */
|
|
281
|
+
function timeAt(tokens: Tok[], i: number): Omit<TimeSpan, 'parts'> | null {
|
|
282
|
+
const t = tokens[i]
|
|
283
|
+
let hour: number, end: number
|
|
284
|
+
if (/^\d{1,2}$/.test(t.low)) { hour = Number.parseInt(t.low, 10); end = i }
|
|
285
|
+
else { const h = spokenHour(tokens, i); if (!h) return null; hour = h.value; end = h.end }
|
|
286
|
+
let minute = 0, hasMinutes = false
|
|
287
|
+
const next = tokens[end + 1]?.low ?? ''
|
|
288
|
+
if (tokens[end].colonNext && /^\d{2}$/.test(next)) { minute = Number.parseInt(next, 10); end++; hasMinutes = true }
|
|
289
|
+
else if (!/^\d/.test(t.low)) { const m = spokenMinutes(tokens, end + 1); if (m) { minute = m.value; end = m.end; hasMinutes = true } }
|
|
290
|
+
const meridiem = MERIDIEM.has(tokens[end + 1]?.low ?? '') ? tokens[end + 1].low : undefined
|
|
291
|
+
if (!hasMinutes && !meridiem) return null
|
|
292
|
+
if (meridiem) end++
|
|
293
|
+
if (hour > 23 || minute > 59) return null
|
|
294
|
+
const { times, explicit } = clockMinutes(hour, minute, meridiem)
|
|
295
|
+
return { start: i, end, times, explicit }
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Every clock time in the text, as time items; the number items inside one become its parts. */
|
|
299
|
+
function addTimes(tokens: Tok[], items: Item[]): void {
|
|
300
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
301
|
+
const found = timeAt(tokens, i)
|
|
302
|
+
if (!found) continue
|
|
303
|
+
const parts = items.filter(it => it.kind === 'number' && it.pos >= found.start && it.pos <= found.end)
|
|
304
|
+
parts.forEach(it => { it.inTime = true })
|
|
305
|
+
const time: TimeSpan = { ...found, parts: parts.map(it => it.key) }
|
|
306
|
+
items.push({ kind: 'number', key: `time:${time.times.join('/')}`, pos: found.start, time })
|
|
307
|
+
i = found.end
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
233
311
|
interface Extract { items: Item[]; tokens: Tok[] }
|
|
234
312
|
|
|
235
313
|
function extract(text: string, knownPhrases: string[][], withNames: boolean): Extract {
|
|
@@ -281,6 +359,7 @@ function extract(text: string, knownPhrases: string[][], withNames: boolean): Ex
|
|
|
281
359
|
if (!t.sentenceStart || midCapital.has(fold(t.low))) items.push({ kind: 'name', key: fold(t.low), pos: i })
|
|
282
360
|
}
|
|
283
361
|
}
|
|
362
|
+
addTimes(tokens, items)
|
|
284
363
|
// Dictionary terms and speaker names, in any case and position, as whole phrases.
|
|
285
364
|
if (withNames) {
|
|
286
365
|
for (const phrase of knownPhrases) {
|
|
@@ -317,6 +396,53 @@ export function looksEnglish(text: string): boolean {
|
|
|
317
396
|
return words.filter(w => ENGLISH.has(w)).length / words.length >= 0.12
|
|
318
397
|
}
|
|
319
398
|
|
|
399
|
+
/**
|
|
400
|
+
* 6.92.0: common function words of German, Portuguese, Spanish, French and Dutch that are not
|
|
401
|
+
* English words, not name particles or place words ("de", "la", "von", "Los", "Las") and not words
|
|
402
|
+
* dictation says in English ("dot com", "em dash", "est", "eu"). Evidence that a short text is not
|
|
403
|
+
* English. An all-caps token ("MIT") never counts.
|
|
404
|
+
*/
|
|
405
|
+
const FOREIGN = new Set(['der', 'das', 'ein', 'eine', 'einen', 'einem', 'einer', 'und', 'ist', 'sind', 'nicht', 'ich', 'wir', 'ihr',
|
|
406
|
+
'sie', 'mit', 'uns', 'auf', 'für', 'uhr', 'auch', 'noch', 'oder', 'aber', 'wie', 'kommt', 'heute', 'morgen', 'bitte', 'danke',
|
|
407
|
+
'haben', 'wird', 'kann', 'zum', 'zur', 'bei', 'nach', 'ja', 'nein',
|
|
408
|
+
'uma', 'não', 'nao', 'por', 'que', 'você', 'voce', 'ele', 'ela', 'nós', 'isso', 'este', 'esta', 'muito', 'obrigado', 'obrigada',
|
|
409
|
+
'amanhã', 'hoje',
|
|
410
|
+
'una', 'unos', 'está', 'pero', 'muy', 'gracias', 'mañana', 'hoy',
|
|
411
|
+
'une', 'je', 'nous', 'vous', 'avec', 'pour', 'pas', 'mais', 'oui', 'merci',
|
|
412
|
+
'het', 'een', 'ik', 'niet', 'wij', 'voor'])
|
|
413
|
+
/** 6.92.0: an English filler sound that is also a word in German, Portuguese, Danish, Norwegian or Dutch. */
|
|
414
|
+
const AMBIGUOUS_FILLERS = new Set(['um', 'er'])
|
|
415
|
+
/**
|
|
416
|
+
* 6.92.0 QA: English evidence beyond the function words, for short replies and commands ("um yes",
|
|
417
|
+
* "er no", "um thanks Dana", "um sounds good", "um Tuesday", "um 5 pm works"). None is a word of
|
|
418
|
+
* the languages above.
|
|
419
|
+
*/
|
|
420
|
+
const SHORT_REPLY_ENGLISH = new Set(['yes', 'no', 'yeah', 'yep', 'ok', 'okay', 'sure', 'thanks', 'thank', 'please', 'sorry', 'hi', 'hey',
|
|
421
|
+
'hello', 'good', 'great', 'fine', 'cool', 'done', 'right', 'maybe', 'sounds', 'call', 'send', 'tell', 'ask', 'am', 'pm',
|
|
422
|
+
'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday',
|
|
423
|
+
'january', 'february', 'march', 'april', 'june', 'july', 'august', 'september', 'october', 'november', 'december'])
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* 6.92.0: the filler pass's language gate (dictation-fillers.ts stripFillersIfEnglish), separate
|
|
427
|
+
* from looksEnglish, which the meaning guard keeps exactly as 6.91.1 had it. Not English when
|
|
428
|
+
* looksEnglish says so (a long text with few English function words, or mostly non-Latin letters).
|
|
429
|
+
* Under 12 words: with a foreign function word in it, English words (function words or the short
|
|
430
|
+
* reply list) must outnumber the foreign ones ("Wir treffen uns um 5 Uhr" is not English); with "um"
|
|
431
|
+
* or "er" said inline (no pause after it), at least one English word must be there ("Comprei um
|
|
432
|
+
* carro" and "Er kommt morgen." keep their um and er; "um yes" and "ship it er tomorrow" do not).
|
|
433
|
+
*/
|
|
434
|
+
export function fillerTextLooksEnglish(text: string): boolean {
|
|
435
|
+
if (!looksEnglish(text)) return false
|
|
436
|
+
const tokens = tokenize(text)
|
|
437
|
+
if (tokens.length > 11) return true
|
|
438
|
+
const english = tokens.filter(t => ENGLISH.has(t.low) || SHORT_REPLY_ENGLISH.has(t.low)).length
|
|
439
|
+
const foreign = tokens.filter(t => FOREIGN.has(t.low) && !(t.raw.length > 1 && t.raw === t.raw.toUpperCase())).length
|
|
440
|
+
if (foreign > 0) return english > foreign
|
|
441
|
+
const inlineAmbiguous = tokens.some((t, i) => AMBIGUOUS_FILLERS.has(t.low) && i + 1 < tokens.length && !tokens[i + 1].pause)
|
|
442
|
+
if (inlineAmbiguous) return english > 0
|
|
443
|
+
return true
|
|
444
|
+
}
|
|
445
|
+
|
|
320
446
|
const zero = (): Record<MeaningGuardKind, number> => ({ negation: 0, number: 0, date: 0, name: 0 })
|
|
321
447
|
|
|
322
448
|
/** How many of each negation, a stutter (the same one twice in a row) counted once. */
|
|
@@ -390,13 +516,35 @@ export function meaningGuardProblem(cleaned: string, answer: string, known: stri
|
|
|
390
516
|
return respelled
|
|
391
517
|
}
|
|
392
518
|
|
|
519
|
+
// 6.91.1: clock times, compared as minutes since midnight (see the header).
|
|
520
|
+
const inTimes = input.items.filter(i => i.time).map(i => i.time!)
|
|
521
|
+
const outTimes = output.items.filter(i => i.time).map(i => i.time!)
|
|
522
|
+
const bareHours = (items: Item[]) => items.filter(i => i.kind === 'number' && !i.time && !i.inTime && /^\d+$/.test(i.key)).map(i => Number(i.key))
|
|
523
|
+
const inBare = bareHours(input.items)
|
|
524
|
+
const outBare = bareHours(output.items)
|
|
525
|
+
const sameTime = (a: TimeSpan, b: TimeSpan) => a.times.some(t => b.times.includes(t))
|
|
526
|
+
// A bare hour matches a time only on that hour: its readings are all on the hour.
|
|
527
|
+
const onTheHour = (time: TimeSpan, n: number) => hourReadings(n).some(t => time.times.includes(t))
|
|
528
|
+
const numberKept = (key: string) => outKeys.get('number')!.has(key) || keptByJoin.has(key)
|
|
529
|
+
const timeKept = (time: TimeSpan) => outTimes.some(o => sameTime(time, o))
|
|
530
|
+
|| (!time.explicit && outBare.some(n => onTheHour(time, n)))
|
|
531
|
+
|| time.parts.every(numberKept)
|
|
532
|
+
// A time in the answer is not new when the dictation had it, had its hour ("at ten" as "10:00 am"),
|
|
533
|
+
// or had its parts as numbers. am, pm and ":00" are format only.
|
|
534
|
+
const timeAccounted = (time: TimeSpan) => inTimes.some(i => sameTime(i, time))
|
|
535
|
+
|| inBare.some(n => onTheHour(time, n))
|
|
536
|
+
|| time.parts.filter(p => !MERIDIEM.has(p) && p !== '0').every(p => inKeys.get('number')!.has(p))
|
|
537
|
+
const kept = (item: Item): boolean => item.kind === 'name' ? nameKept(item.key)
|
|
538
|
+
: item.time ? timeKept(item.time)
|
|
539
|
+
: item.kind === 'number' ? numberKept(item.key) || (/^\d+$/.test(item.key) && outTimes.some(o => onTheHour(o, Number(item.key))))
|
|
540
|
+
: outKeys.get(item.kind)!.has(item.key)
|
|
541
|
+
|
|
393
542
|
// A dropped token with a correction marker after it and a same-kind replacement the answer kept.
|
|
394
543
|
const exempt = (item: Item): boolean => {
|
|
395
544
|
if (item.kind === 'negation') return false
|
|
396
545
|
for (const { start, end, reach } of spans) {
|
|
397
546
|
if (start <= item.pos || start - item.pos > DROP_TO_MARKER) continue
|
|
398
|
-
const replaced = input.items.some(r => r.kind === item.kind && r.key !== item.key && r.pos > end && r.pos - end <= reach
|
|
399
|
-
&& (r.kind === 'name' ? nameKept(r.key) : outKeys.get(r.kind)!.has(r.key)))
|
|
547
|
+
const replaced = input.items.some(r => r.kind === item.kind && r.key !== item.key && r.pos > end && r.pos - end <= reach && kept(r))
|
|
400
548
|
if (replaced) return true
|
|
401
549
|
}
|
|
402
550
|
return false
|
|
@@ -414,9 +562,9 @@ export function meaningGuardProblem(cleaned: string, answer: string, known: stri
|
|
|
414
562
|
const id = `${item.kind}:${item.key}`
|
|
415
563
|
if (seen.has(id)) continue
|
|
416
564
|
if (item.kind === 'negation' && countNegations) continue
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
if (kept) { seen.add(id); continue }
|
|
565
|
+
// A number inside a clock time is judged with its time.
|
|
566
|
+
if (item.inTime) continue
|
|
567
|
+
if (kept(item)) { seen.add(id); continue }
|
|
420
568
|
// Every occurrence must be exempt for a drop to be excused.
|
|
421
569
|
const occurrences = input.items.filter(i => i.kind === item.kind && i.key === item.key)
|
|
422
570
|
seen.add(id)
|
|
@@ -437,7 +585,9 @@ export function meaningGuardProblem(cleaned: string, answer: string, known: stri
|
|
|
437
585
|
const outNumberItems = output.items.filter(i => i.kind === 'number')
|
|
438
586
|
for (const key of outNumbers) if (outNumberItems.filter(i => i.key === key).every(i => i.list)) listOnly.add(key)
|
|
439
587
|
}
|
|
440
|
-
|
|
588
|
+
const plainOut = new Set(output.items.filter(i => i.kind === 'number' && !i.time && !i.inTime).map(i => i.key))
|
|
589
|
+
for (const key of plainOut) if (!inKeys.get('number')!.has(key) && !joinedOut.has(key) && !listOnly.has(key)) added.number++
|
|
590
|
+
for (const time of outTimes) if (!timeAccounted(time)) added.number++
|
|
441
591
|
|
|
442
592
|
const kind = MEANING_GUARD_KINDS.find(k => dropped[k] > 0 || added[k] > 0)
|
|
443
593
|
return kind ? { kind, dropped, added, namesSkipped: !withNames } : null
|
|
@@ -5,7 +5,7 @@ import { applyFuzzyCorrections } from './fuzzy-correct.js'
|
|
|
5
5
|
import { getAllSpeakerNames } from './speaker-embeddings.js'
|
|
6
6
|
import { autoCleanDictation, AUTOCLEAN_MAX_CHARS } from './dictation-clean.js'
|
|
7
7
|
import { dictationPolishPromptVersion, styleAndRegisterApply, type DictationPolishPromptVersion } from './dictation-polish-options.js'
|
|
8
|
-
import { fillerPassEnabled,
|
|
8
|
+
import { fillerPassEnabled, stripFillersIfEnglish } from './dictation-fillers.js'
|
|
9
9
|
import { polishBudgetExhausted, recordPolishCall, type PolishBucket } from './dictation-polish-budget.js'
|
|
10
10
|
import { polishNeed, skipCleanEnabled } from './dictation-polish-need.js'
|
|
11
11
|
import { discardPolishPrewarm, PrewarmUnavailableError, runWarmPolish, takePolishPrewarm } from './dictation-prewarm.js'
|
|
@@ -16,7 +16,7 @@ import { customInstructionsHash, readCustomInstructions } from './dictation-poli
|
|
|
16
16
|
import { learnedStyleEnabled, learnedStyleLinesFor, readLearnedStyle } from './dictation-learned-style.js'
|
|
17
17
|
import { literalAnswer, type DictationRegister } from './dictation-register.js'
|
|
18
18
|
import { meaningGuardCounts, meaningGuardEnabled, meaningGuardProblem, type MeaningGuardKind } from './dictation-meaning-guard.js'
|
|
19
|
-
import { createBreaker } from './claude-circuit.js'
|
|
19
|
+
import { createBreaker, type ClaudeBreaker } from './claude-circuit.js'
|
|
20
20
|
import { logTokenAudit } from './token-audit.js'
|
|
21
21
|
|
|
22
22
|
/**
|
|
@@ -34,10 +34,60 @@ import { logTokenAudit } from './token-audit.js'
|
|
|
34
34
|
* register, no instructions, no learned style, no meaning guard.
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
37
|
+
/**
|
|
38
|
+
* 6.92.0 (plan r3, A-W0a): the breakers are keyed by (model, bucket group). Meeting lines have their
|
|
39
|
+
* own, so a G2 meeting that fails can no longer turn off Mac dictation and G2 message polish; message
|
|
40
|
+
* and prompt_draft share the `dictation` group. Each key has two breakers:
|
|
41
|
+
*
|
|
42
|
+
* - failure: 2 consecutive CLI failures open it for 30 minutes, as before. A client abort (the
|
|
43
|
+
* request closed, `signal.aborted`) is neutral: it neither counts nor resets.
|
|
44
|
+
* - slow: COS_DICTATION_SLOW_BREAKER_ABORTS (default 4) consecutive aborts, each after at least
|
|
45
|
+
* SLOW_ABORT_MIN_MODEL_MS of model time, with no success between, open it for
|
|
46
|
+
* COS_DICTATION_SLOW_BREAKER_MS (default 300000, 5 minutes). An abort sooner than that (a disconnect
|
|
47
|
+
* right after a sidecar restart) does not count.
|
|
48
|
+
*
|
|
49
|
+
* Either one open skips the polish as `breaker_open`; any success closes both.
|
|
50
|
+
*/
|
|
51
|
+
export type PolishBreakerGroup = 'dictation' | 'meeting'
|
|
52
|
+
export const SLOW_ABORT_MIN_MODEL_MS = 4_000
|
|
53
|
+
|
|
54
|
+
export function breakerGroupFor(bucket: PolishBucket): PolishBreakerGroup {
|
|
55
|
+
return bucket === 'meeting' ? 'meeting' : 'dictation'
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function envPositiveInt(raw: string | undefined, fallback: number): number {
|
|
59
|
+
const trimmed = raw?.trim() ?? ''
|
|
60
|
+
if (!/^\d+$/.test(trimmed)) return fallback
|
|
61
|
+
const value = Number(trimmed)
|
|
62
|
+
return value > 0 ? value : fallback
|
|
63
|
+
}
|
|
64
|
+
export const slowBreakerAborts = (env: NodeJS.ProcessEnv = process.env) => envPositiveInt(env.COS_DICTATION_SLOW_BREAKER_ABORTS, 4)
|
|
65
|
+
export const slowBreakerMs = (env: NodeJS.ProcessEnv = process.env) => envPositiveInt(env.COS_DICTATION_SLOW_BREAKER_MS, 300_000)
|
|
66
|
+
|
|
67
|
+
interface PolishBreakers { failure: ClaudeBreaker; slow: ClaudeBreaker }
|
|
68
|
+
const BREAKER_MODELS = ['sonnet', 'luna-5.6-fast', 'haiku'] as const
|
|
69
|
+
function breakersFor(group: PolishBreakerGroup): Record<DictationCleanModel, PolishBreakers> {
|
|
70
|
+
return Object.fromEntries(BREAKER_MODELS.map(model => {
|
|
71
|
+
const label = `dictation-autoclean-${model === 'luna-5.6-fast' ? 'luna' : model}${group === 'meeting' ? '-meeting' : ''}`
|
|
72
|
+
return [model, {
|
|
73
|
+
failure: createBreaker({ label }),
|
|
74
|
+
slow: createBreaker({ label: `${label}-slow`, maxFailures: () => slowBreakerAborts(), cooldownMs: () => slowBreakerMs() }),
|
|
75
|
+
}]
|
|
76
|
+
})) as Record<DictationCleanModel, PolishBreakers>
|
|
77
|
+
}
|
|
78
|
+
export const autoCleanBreakers: Record<PolishBreakerGroup, Record<DictationCleanModel, PolishBreakers>> = {
|
|
79
|
+
dictation: breakersFor('dictation'),
|
|
80
|
+
meeting: breakersFor('meeting'),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function polishBreakersFor(model: DictationCleanModel, bucket: PolishBucket): PolishBreakers {
|
|
84
|
+
return autoCleanBreakers[breakerGroupFor(bucket)][model]
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** The keyed breakers for this model and bucket: open when either is. The prewarm route asks this too. */
|
|
88
|
+
export function polishBreakerOpen(model: DictationCleanModel, bucket: PolishBucket): boolean {
|
|
89
|
+
const breakers = polishBreakersFor(model, bucket)
|
|
90
|
+
return breakers.failure.isOpen() || breakers.slow.isOpen()
|
|
41
91
|
}
|
|
42
92
|
const autoCleanDefaultEnabled = () => ['1', 'true', 'on'].includes((process.env.COS_DICTATION_AUTOCLEAN ?? '').toLowerCase())
|
|
43
93
|
|
|
@@ -155,7 +205,8 @@ export function prepareOutboundDictation(text: string, bucket: PolishBucket): st
|
|
|
155
205
|
console.warn(`[prompt-draft] Fuzzy correction failed (non-fatal): ${fuzzyErr?.message ?? fuzzyErr}`)
|
|
156
206
|
}
|
|
157
207
|
// 6.79.1: um/uh never survive a skipped polish. Meeting lines stay faithful.
|
|
158
|
-
|
|
208
|
+
// 6.92.0: only when the text is mostly English ("um 5 Uhr" and "um carro" are words).
|
|
209
|
+
if (bucket !== 'meeting' && fillerPassEnabled()) cleaned = stripFillersIfEnglish(cleaned)
|
|
159
210
|
// 6.83.1: "forward slash pricing," is "/pricing," even when the model is skipped. One word
|
|
160
211
|
// at a phrase end only; slugs of several words are the model's (dictation-format.ts).
|
|
161
212
|
if (bucket !== 'meeting' && spokenPathsEnabled()) cleaned = renderSpokenPaths(cleaned)
|
|
@@ -221,11 +272,11 @@ export async function polishOutboundDictation(
|
|
|
221
272
|
const cleaned = prepareOutboundDictation(text, opts.bucket)
|
|
222
273
|
if (!(opts.enabled ?? autoCleanDefaultEnabled())) return { text: cleaned, polishSkipped: 'disabled' }
|
|
223
274
|
const model = opts.model ?? 'haiku'
|
|
224
|
-
const
|
|
275
|
+
const breakers = polishBreakersFor(model, opts.bucket)
|
|
225
276
|
if (cleaned.length > AUTOCLEAN_MAX_CHARS) return { text: cleaned, polishSkipped: 'too_long' }
|
|
226
277
|
// 6.79.2, off by default: nothing for the model to fix (see dictation-polish-need.ts).
|
|
227
278
|
if (skipCleanEnabled() && polishNeed(cleaned) === null) return { text: cleaned, polishSkipped: 'clean' }
|
|
228
|
-
if (
|
|
279
|
+
if (polishBreakerOpen(model, opts.bucket)) return { text: cleaned, polishSkipped: 'breaker_open' }
|
|
229
280
|
if (polishBudgetExhausted(opts.bucket)) return { text: cleaned, polishSkipped: 'daily_cap' }
|
|
230
281
|
const startedAt = Date.now()
|
|
231
282
|
recordPolishCall(opts.bucket)
|
|
@@ -250,6 +301,11 @@ export async function polishOutboundDictation(
|
|
|
250
301
|
// 6.79.2: a warm process started at key press answers without the CLI's start-up cost.
|
|
251
302
|
const warmEntry = opts.prewarmToken ? takePolishPrewarm(opts.prewarmToken, model) : null
|
|
252
303
|
let prewarm: PrewarmOutcome | undefined = opts.prewarmToken ? 'missed' : undefined
|
|
304
|
+
// 6.92.0 QA S1: the model time of an abort is stamped when the abort happens, not in the catch,
|
|
305
|
+
// which runs only after the provider process is terminated (up to 4 s on the cold path).
|
|
306
|
+
let abortedAt: number | null = null
|
|
307
|
+
const onAbort = () => { abortedAt = Date.now() }
|
|
308
|
+
opts.signal?.addEventListener('abort', onAbort, { once: true })
|
|
253
309
|
try {
|
|
254
310
|
let polished: string | null = null
|
|
255
311
|
if (warmEntry) {
|
|
@@ -266,7 +322,8 @@ export async function polishOutboundDictation(
|
|
|
266
322
|
// one; this holds if either ever stops): the input goes out, polishSkipped "failed", and the
|
|
267
323
|
// breaker counts it. Never "unchanged".
|
|
268
324
|
if (!polished) throw new Error('Auto-clean returned empty text')
|
|
269
|
-
|
|
325
|
+
breakers.failure.recordSuccess()
|
|
326
|
+
breakers.slow.recordSuccess()
|
|
270
327
|
const guarded = guardPolishAnswer(cleaned, polished, opts.bucket, register, canaried)
|
|
271
328
|
logTokenAudit({
|
|
272
329
|
source: 'g2-dictation-autoclean', model, inputChars: cleaned.length, outputChars: polished.length,
|
|
@@ -285,8 +342,29 @@ export async function polishOutboundDictation(
|
|
|
285
342
|
modelText: polished, ...used,
|
|
286
343
|
}
|
|
287
344
|
} catch (err: any) {
|
|
288
|
-
|
|
289
|
-
|
|
345
|
+
if (opts.signal?.aborted) {
|
|
346
|
+
// 6.92.0: the client went away (Control stopped waiting, the phone closed the request). Not a
|
|
347
|
+
// CLI failure: only the slow breaker hears it, and only after real model time. The audit row
|
|
348
|
+
// says `aborted`, codes only; the answer (nobody reads it) keeps PolishOutcome's three values.
|
|
349
|
+
const durationMs = (abortedAt ?? Date.now()) - startedAt
|
|
350
|
+
if (durationMs >= SLOW_ABORT_MIN_MODEL_MS) breakers.slow.recordFailure()
|
|
351
|
+
logTokenAudit({
|
|
352
|
+
source: 'g2-dictation-autoclean', model, inputChars: cleaned.length, outputChars: 0,
|
|
353
|
+
durationMs, caller: `dictation_autoclean_${opts.bucket}`,
|
|
354
|
+
// Key order differs from the answered row's on purpose: each gate anchor matches one row.
|
|
355
|
+
polishInstructions: { hash: instructionsHash, on: Boolean(customInstructions) },
|
|
356
|
+
learnedStyle: { hash: style.hash, on: Boolean(style.lines) },
|
|
357
|
+
...(canaried ? { polishRegister: register } : {}),
|
|
358
|
+
...(used.polishPrompt ? { polishPrompt: used.polishPrompt } : {}),
|
|
359
|
+
polishOutcome: 'aborted',
|
|
360
|
+
})
|
|
361
|
+
console.warn('[prompt-draft] auto-clean aborted by the client (glossary-only)')
|
|
362
|
+
} else {
|
|
363
|
+
breakers.failure.recordFailure()
|
|
364
|
+
console.warn(`[prompt-draft] auto-clean failed (glossary-only): ${err?.message ?? err}`)
|
|
365
|
+
}
|
|
290
366
|
return { text: cleaned, polishSkipped: 'failed', ...(prewarm ? { prewarm } : {}), ...used }
|
|
367
|
+
} finally {
|
|
368
|
+
opts.signal?.removeEventListener('abort', onAbort)
|
|
291
369
|
}
|
|
292
370
|
}
|
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs'
|
|
3
|
+
import { dirname } from 'node:path'
|
|
2
4
|
import { claudeKeySecretsGuardArgs } from './key-secrets-guard.js'
|
|
5
|
+
import { runClaudeText } from './claude-text-runner.js'
|
|
6
|
+
import { createBreaker } from './claude-circuit.js'
|
|
7
|
+
import { readSwitch } from './env-switch.js'
|
|
8
|
+
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
9
|
+
import { dataPath } from './data-dir.js'
|
|
10
|
+
import { localDay } from './dictation-polish-budget.js'
|
|
3
11
|
|
|
4
12
|
export const PROMPT_EDIT_DRAFT_MAX_CHARS = 24_000
|
|
5
13
|
export const PROMPT_EDIT_INSTRUCTION_MAX_CHARS = 2_000
|
|
@@ -178,17 +186,90 @@ export function spawnClaudeText(prompt: string, opts: SpawnClaudeTextOptions = {
|
|
|
178
186
|
})
|
|
179
187
|
}
|
|
180
188
|
|
|
189
|
+
export const PROMPT_EDIT_SYSTEM_PROMPT = 'You revise dictated prompt drafts. Output only the revised prompt text.'
|
|
190
|
+
export const promptEditModel = (env: NodeJS.ProcessEnv = process.env) => env.COS_PROMPT_EDIT_MODEL || 'sonnet'
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* G2's prompt edit (POST /api/prompt-edit). 6.92.0: through the isolated text runner
|
|
194
|
+
* (claude-text-runner.ts: no tools, MCP, hooks or session, a scratch directory), with the same
|
|
195
|
+
* model, effort and 45 s timeout settings as before.
|
|
196
|
+
*/
|
|
181
197
|
export async function applyPromptEdit(input: PromptEditInput, signal?: AbortSignal): Promise<string> {
|
|
182
|
-
return
|
|
183
|
-
model:
|
|
198
|
+
return runClaudeText(buildPromptEditPrompt(input), {
|
|
199
|
+
model: promptEditModel(),
|
|
184
200
|
effort: process.env.COS_PROMPT_EDIT_EFFORT || 'low',
|
|
185
201
|
timeoutMs: Number.parseInt(process.env.COS_PROMPT_EDIT_TIMEOUT_MS || '45000', 10),
|
|
186
|
-
systemPrompt:
|
|
202
|
+
systemPrompt: PROMPT_EDIT_SYSTEM_PROMPT,
|
|
187
203
|
label: 'Prompt edit',
|
|
188
204
|
signal,
|
|
189
205
|
})
|
|
190
206
|
}
|
|
191
207
|
|
|
208
|
+
// ── 6.92.0 (plan r3, A-W0b): how prompt edit stops ───────────────────
|
|
209
|
+
//
|
|
210
|
+
// Every `claude -p` caller answers how it stops. Prompt edit had no answer through 6.91.1:
|
|
211
|
+
// - master switch: COS_PROMPT_EDIT (readSwitch; on by default, 0/false/off answers 503);
|
|
212
|
+
// - daily cap: COS_PROMPT_EDIT_DAILY_CAP (default 200 a local day) in its OWN counter file, never
|
|
213
|
+
// the dictation polish counter, so editing a prompt never spends Mac dictation's budget;
|
|
214
|
+
// - breaker: 3 consecutive CLI failures open it for 10 minutes; a client abort is neutral;
|
|
215
|
+
// - an audit row per call that ran (caller prompt_edit, codes and sizes only).
|
|
216
|
+
// Refusals are text/plain bodies of 24 characters or fewer, so G2 6.10.654 renders
|
|
217
|
+
// "Edit failed.\nAPI <status>: <body>" in 40 characters or fewer.
|
|
218
|
+
|
|
219
|
+
export const PROMPT_EDIT_REFUSALS = {
|
|
220
|
+
cap: { status: 429, body: 'Edit limit reached' },
|
|
221
|
+
off: { status: 503, body: 'Edit is off' },
|
|
222
|
+
paused: { status: 503, body: 'Edit paused' },
|
|
223
|
+
failed: { status: 500, body: 'Edit failed' },
|
|
224
|
+
} as const
|
|
225
|
+
export type PromptEditRefusal = keyof typeof PROMPT_EDIT_REFUSALS
|
|
226
|
+
|
|
227
|
+
export const PROMPT_EDIT_DEFAULT_DAILY_CAP = 200
|
|
228
|
+
export const PROMPT_EDIT_BREAKER_FAILURES = 3
|
|
229
|
+
export const PROMPT_EDIT_BREAKER_MS = 10 * 60 * 1000
|
|
230
|
+
export const PROMPT_EDIT_COUNT_FILE = '.prompt_edit_count.json'
|
|
231
|
+
|
|
232
|
+
export function promptEditEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
233
|
+
return readSwitch(env.COS_PROMPT_EDIT, true)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export function promptEditDailyCap(env: NodeJS.ProcessEnv = process.env): number {
|
|
237
|
+
const trimmed = env.COS_PROMPT_EDIT_DAILY_CAP?.trim() ?? ''
|
|
238
|
+
if (!/^\d+$/.test(trimmed)) return PROMPT_EDIT_DEFAULT_DAILY_CAP
|
|
239
|
+
const value = Number(trimmed)
|
|
240
|
+
return value > 0 ? value : PROMPT_EDIT_DEFAULT_DAILY_CAP
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export const promptEditBreaker = createBreaker({
|
|
244
|
+
label: 'prompt-edit', maxFailures: PROMPT_EDIT_BREAKER_FAILURES, cooldownMs: PROMPT_EDIT_BREAKER_MS,
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
const countFile = () => dataPath(PROMPT_EDIT_COUNT_FILE)
|
|
248
|
+
|
|
249
|
+
export function promptEditCountToday(): number {
|
|
250
|
+
try {
|
|
251
|
+
const raw = JSON.parse(readFileSync(countFile(), 'utf8'))
|
|
252
|
+
if (raw?.date !== localDay() || !Number.isInteger(raw.count) || raw.count < 0) return 0
|
|
253
|
+
return raw.count
|
|
254
|
+
} catch { return 0 }
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export function recordPromptEditCall(): void {
|
|
258
|
+
try {
|
|
259
|
+
const file = countFile()
|
|
260
|
+
if (!existsSync(dirname(file))) mkdirSync(dirname(file), { recursive: true })
|
|
261
|
+
atomicWriteFileSync(file, JSON.stringify({ date: localDay(), count: promptEditCountToday() + 1 }), { mode: 0o600 })
|
|
262
|
+
} catch {}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** The refusal for this request, before any call, or null when the edit may run. Switch, breaker, cap. */
|
|
266
|
+
export function promptEditRefusal(env: NodeJS.ProcessEnv = process.env): PromptEditRefusal | null {
|
|
267
|
+
if (!promptEditEnabled(env)) return 'off'
|
|
268
|
+
if (promptEditBreaker.isOpen()) return 'paused'
|
|
269
|
+
if (promptEditCountToday() >= promptEditDailyCap(env)) return 'cap'
|
|
270
|
+
return null
|
|
271
|
+
}
|
|
272
|
+
|
|
192
273
|
// ── Outbound dictation auto-clean ────────────────────────────────────
|
|
193
274
|
export const AUTOCLEAN_MAX_CHARS = 8_000
|
|
194
275
|
|
|
@@ -42,9 +42,12 @@ export interface TokenAuditEntry extends TokenAuditKeyFields {
|
|
|
42
42
|
polishRegister?: string
|
|
43
43
|
/** 6.91.0, dictation polish only: the prompt that ran (v3 or v4). */
|
|
44
44
|
polishPrompt?: string
|
|
45
|
-
/** 6.91.0, dictation polish only: what became of the model's answer (polished, unchanged, guarded) and the guard kind.
|
|
45
|
+
/** 6.91.0, dictation polish only: what became of the model's answer (polished, unchanged, guarded) and the guard kind.
|
|
46
|
+
* 6.92.0: `aborted` (audit only, never in a finalize answer) when the client went away mid-call. */
|
|
46
47
|
polishOutcome?: string
|
|
47
48
|
polishGuard?: string
|
|
49
|
+
/** 6.92.0, prompt edit only: ok, failed or aborted. */
|
|
50
|
+
promptEditOutcome?: 'ok' | 'failed' | 'aborted'
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
/**
|
|
@@ -86,6 +89,8 @@ export function buildTokenAuditRecord(entry: TokenAuditEntry, now: Date = new Da
|
|
|
86
89
|
...(entry.polishPrompt ? { polish_prompt: entry.polishPrompt } : {}),
|
|
87
90
|
...(entry.polishOutcome ? { polish_outcome: entry.polishOutcome } : {}),
|
|
88
91
|
...(entry.polishGuard ? { polish_guard: entry.polishGuard } : {}),
|
|
92
|
+
// 6.92.0: set only by the prompt-edit caller.
|
|
93
|
+
...(entry.promptEditOutcome ? { prompt_edit_outcome: entry.promptEditOutcome } : {}),
|
|
89
94
|
}
|
|
90
95
|
}
|
|
91
96
|
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
} from '../lib/dictation-polish-options.js'
|
|
47
47
|
import { LEARNED_STYLE_INTRO, LEARNED_STYLE_LIMITS, learnedStyleBlock, LITERAL_REGISTER_RULES, V4_CLOSING_RULE, V4_LITERAL_CLOSING_RULE } from '../lib/dictation-clean.js'
|
|
48
48
|
import { publishedRegisters, registersEnabled } from '../lib/dictation-register.js'
|
|
49
|
+
import { meaningGuardEnabled } from '../lib/dictation-meaning-guard.js'
|
|
49
50
|
|
|
50
51
|
/** Where the instructions apply (prompt-drafts.ts polishInstructionsFor): never meeting lines. */
|
|
51
52
|
export const CUSTOM_INSTRUCTIONS_APPLY_TO = ['message', 'prompt_draft'] as const
|
|
@@ -248,11 +249,17 @@ dictationPolishSettingsRouter.delete('/dictation/learned-style', (_req, res) =>
|
|
|
248
249
|
function optionsView() {
|
|
249
250
|
const stored = readPolishOptions()
|
|
250
251
|
const choice = dictationPolishPromptChoice()
|
|
252
|
+
const effectiveByModel = Object.fromEntries(POLISH_MODEL_DEFAULTS.map(model => [model, dictationPolishPromptChoice(process.env, model).version]))
|
|
251
253
|
return {
|
|
252
254
|
promptVersion: stored.promptVersion,
|
|
253
255
|
effectiveVersion: choice.version,
|
|
254
256
|
// 6.91.0 option C: the prompt each model actually gets.
|
|
255
|
-
effectiveByModel
|
|
257
|
+
effectiveByModel,
|
|
258
|
+
// 6.92.0 (plan r3, A-W2): what Control's picker line needs, additive. The pairs the meaning guard,
|
|
259
|
+
// Your style and the register apply to (the same list GET /rules computes), and whether the
|
|
260
|
+
// meaning guard is on (COS_DICTATION_MEANING_GUARD). Older servers omit both.
|
|
261
|
+
stylePairs: POLISH_MODEL_DEFAULTS.filter(model => styleAndRegisterApply(model, effectiveByModel[model])).map(model => ({ model, prompt: effectiveByModel[model] })),
|
|
262
|
+
meaningGuard: meaningGuardEnabled(),
|
|
256
263
|
source: choice.source,
|
|
257
264
|
// The environment named a version, so this option has no effect until it is removed.
|
|
258
265
|
envOverride: choice.source === 'env',
|
|
@@ -42,7 +42,7 @@ import { polishBudgetExhausted } from '../lib/dictation-polish-budget.js'
|
|
|
42
42
|
import { discardPolishPrewarm, isPrewarmModel, prewarmEnabled, startPolishPrewarm } from '../lib/dictation-prewarm.js'
|
|
43
43
|
import { applyCorrections } from '../lib/whisper-local.js'
|
|
44
44
|
import { registerFromContext } from '../lib/dictation-register.js'
|
|
45
|
-
import {
|
|
45
|
+
import { cleanOutboundDictation, polishBreakerOpen, type AutoCleanRequest, type PolishResult } from '../lib/dictation-polish-pipeline.js'
|
|
46
46
|
import { emitDisplay } from '../lib/display-bus.js'
|
|
47
47
|
import {
|
|
48
48
|
acquireMaintenanceWork,
|
|
@@ -158,7 +158,8 @@ promptDraftsRouter.post('/dictation/polish/prewarm', (req, res) => {
|
|
|
158
158
|
if (surface !== 'message' && surface !== 'meeting') return res.status(400).json({ error: 'invalid_surface' })
|
|
159
159
|
if (!prewarmEnabled()) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'disabled' })
|
|
160
160
|
if (!isPrewarmModel(model)) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'model' })
|
|
161
|
-
|
|
161
|
+
// 6.92.0: the same keyed breaker (model, bucket group) the finalize for this surface would ask.
|
|
162
|
+
if (polishBreakerOpen(model, surface)) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'breaker_open' })
|
|
162
163
|
if (polishBudgetExhausted(surface)) return res.status(409).json({ error: 'prewarm_unavailable', reason: 'daily_cap' })
|
|
163
164
|
try {
|
|
164
165
|
res.json(startPolishPrewarm(model))
|