@gotcos/glasses-server 6.90.0 → 6.91.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/.env.example +21 -0
- package/CHANGELOG.md +178 -0
- package/README.md +69 -4
- package/managed-runtime-contract.json +5 -1
- package/package.json +2 -1
- package/server/lib/dictation-clean.ts +151 -14
- package/server/lib/dictation-learned-style.ts +203 -0
- package/server/lib/dictation-meaning-guard.ts +552 -0
- package/server/lib/dictation-polish-instructions.ts +2 -2
- package/server/lib/dictation-polish-options.ts +98 -0
- package/server/lib/dictation-polish-pipeline.ts +292 -0
- package/server/lib/dictation-prewarm.ts +18 -6
- package/server/lib/dictation-register.ts +114 -0
- package/server/lib/token-audit.ts +18 -0
- package/server/routes/dictation-polish-settings.ts +193 -3
- package/server/routes/health.ts +11 -3
- package/server/routes/prompt-drafts.ts +35 -140
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 6.91.0: the meaning guard (plan r2). Deterministic, after the polish model, beside the link
|
|
3
|
+
* guard (dictation-format.ts polishLinkProblem), on the `message` and `prompt_draft` buckets, and
|
|
4
|
+
* only where prompt v4 runs on Sonnet (the canaried pair; dictation-polish-pipeline.ts). Haiku,
|
|
5
|
+
* Luna, any other model and prompt v3 get no guard, so their answers are 6.90.0's.
|
|
6
|
+
*
|
|
7
|
+
* Its input is `cleaned`, the text the model was given (after the dictionary, fuzzy and filler
|
|
8
|
+
* passes), never the raw heard text, the same input the link guard uses. It compares SETS of
|
|
9
|
+
* guarded tokens, in four kinds:
|
|
10
|
+
* - negation: not / n't / cannot and the forms speech-to-text writes without an apostrophe ("dont",
|
|
11
|
+
* "cant"), all one NEG token; never, no, none, nothing, nobody, neither, nor, without, unless,
|
|
12
|
+
* except;
|
|
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"), 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");
|
|
24
|
+
* - date: a weekday or a month;
|
|
25
|
+
* - name: a mid-sentence capital, a dictionary term or a speaker name. A sentence-initial word and
|
|
26
|
+
* "I" are not names unless they are dictionary terms. Skipped for a dictation that is not English.
|
|
27
|
+
*
|
|
28
|
+
* Normalization: case-insensitive; split on - / @ . and every other mark; number words become
|
|
29
|
+
* digits ("twenty twenty six" may come back as "2026"); possessives and plurals fold; a name
|
|
30
|
+
* re-spelled to a dictionary term (or to a near form the model was asked to fix) counts as kept.
|
|
31
|
+
*
|
|
32
|
+
* It trips on a DROPPED token of any kind, and on an ADDED negation or number. With no correction
|
|
33
|
+
* marker in the dictation, negations are COUNTED ("not saying we should not ship" losing one "not"
|
|
34
|
+
* trips; a stutter, the same negation twice in a row, counts once); with one, they are compared as
|
|
35
|
+
* sets. A dropped token is exempt only when a correction marker follows it and a token of the SAME
|
|
36
|
+
* kind replaces it within a few words ("five, actually six"; "Priya ... actually Dana"; "Tuesday,
|
|
37
|
+
* no wait, Wednesday"). "actually" alone is a marker only when punctuation sets it off or the
|
|
38
|
+
* replacement follows within 3 words; "wait" alone only when punctuation sets it off, because it is
|
|
39
|
+
* an ordinary verb ("tell Marco to wait for Dana"). A negation is never exempt; only a marker
|
|
40
|
+
* phrase's own words ("no wait", "wait no", ", no,", "never mind") are not negations. When the
|
|
41
|
+
* speaker asked for a list ("numbered list", "number one", "bullet points"), a list number at the
|
|
42
|
+
* start of a line ("1." or "2)") is never an added number. Known gaps, stated and not claimed:
|
|
43
|
+
* reordering, before/after/only, and a list number standing in for a dropped number of the same
|
|
44
|
+
* value. Pure; it never throws on strings and logs nothing.
|
|
45
|
+
*/
|
|
46
|
+
export type MeaningGuardKind = 'negation' | 'number' | 'date' | 'name'
|
|
47
|
+
export const MEANING_GUARD_KINDS: readonly MeaningGuardKind[] = ['negation', 'number', 'date', 'name']
|
|
48
|
+
|
|
49
|
+
export interface MeaningGuardProblem {
|
|
50
|
+
kind: MeaningGuardKind
|
|
51
|
+
dropped: Record<MeaningGuardKind, number>
|
|
52
|
+
added: Record<MeaningGuardKind, number>
|
|
53
|
+
/** True when the name check was skipped because the dictation is not English. */
|
|
54
|
+
namesSkipped: boolean
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function meaningGuardEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
58
|
+
const raw = (env.COS_DICTATION_MEANING_GUARD ?? '').trim().toLowerCase()
|
|
59
|
+
return !(raw === '0' || raw === 'false' || raw === 'off')
|
|
60
|
+
}
|
|
61
|
+
|
|
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[] }
|
|
66
|
+
|
|
67
|
+
const ONES = new Map(['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve',
|
|
68
|
+
'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'].map((w, i) => [w, i]))
|
|
69
|
+
const TENS = new Map([['twenty', 20], ['thirty', 30], ['forty', 40], ['fourty', 40], ['fifty', 50], ['sixty', 60], ['seventy', 70], ['eighty', 80], ['ninety', 90]])
|
|
70
|
+
const SCALES = new Map([['hundred', 100], ['thousand', 1_000], ['k', 1_000], ['million', 1_000_000], ['billion', 1_000_000_000]])
|
|
71
|
+
const ORDINALS = new Map([['first', 1], ['second', 2], ['third', 3], ['fourth', 4], ['fifth', 5], ['sixth', 6], ['seventh', 7], ['eighth', 8],
|
|
72
|
+
['ninth', 9], ['tenth', 10], ['eleventh', 11], ['twelfth', 12], ['thirteenth', 13], ['fourteenth', 14], ['fifteenth', 15],
|
|
73
|
+
['sixteenth', 16], ['seventeenth', 17], ['eighteenth', 18], ['nineteenth', 19], ['twentieth', 20], ['thirtieth', 30], ['fortieth', 40],
|
|
74
|
+
['fiftieth', 50], ['sixtieth', 60], ['seventieth', 70], ['eightieth', 80], ['ninetieth', 90]])
|
|
75
|
+
/** Unit and time words that are part of a number item. */
|
|
76
|
+
const NUMBER_WORDS = new Map([['percent', 'percent'], ['percentage', 'percent'], ['dollar', 'dollar'], ['dollars', 'dollar'], ['buck', 'dollar'],
|
|
77
|
+
['bucks', 'dollar'], ['euro', 'euro'], ['euros', 'euro'], ['pound', 'pound'], ['pounds', 'pound'], ['cent', 'cent'], ['cents', 'cent'],
|
|
78
|
+
['noon', 'noon'], ['midnight', 'midnight']])
|
|
79
|
+
const SYMBOLS = new Map([['%', 'percent'], ['$', 'dollar'], ['€', 'euro'], ['£', 'pound'], ['¢', 'cent']])
|
|
80
|
+
|
|
81
|
+
const NEG_WORDS = new Set(['never', 'no', 'none', 'nothing', 'nobody', 'neither', 'nor', 'without', 'unless', 'except'])
|
|
82
|
+
/** "n't" forms speech-to-text writes without the apostrophe: the same NEG token as "don't". */
|
|
83
|
+
const BARE_NT = new Set(['dont', 'cant', 'wont', 'isnt', 'arent', 'doesnt', 'didnt', 'wasnt', 'werent', 'shouldnt', 'wouldnt', 'couldnt',
|
|
84
|
+
'havent', 'hasnt', 'hadnt', 'mustnt', 'neednt', 'aint'])
|
|
85
|
+
|
|
86
|
+
const WEEKDAYS = new Map([['monday', 'monday'], ['tuesday', 'tuesday'], ['wednesday', 'wednesday'], ['thursday', 'thursday'], ['friday', 'friday'],
|
|
87
|
+
['saturday', 'saturday'], ['sunday', 'sunday'], ['mon', 'monday'], ['tue', 'tuesday'], ['tues', 'tuesday'], ['wed', 'wednesday'],
|
|
88
|
+
['thu', 'thursday'], ['thur', 'thursday'], ['thurs', 'thursday'], ['fri', 'friday'], ['sat', 'saturday'], ['sun', 'sunday']])
|
|
89
|
+
const MONTHS = new Map([['january', 'january'], ['february', 'february'], ['march', 'march'], ['april', 'april'], ['may', 'may'], ['june', 'june'],
|
|
90
|
+
['july', 'july'], ['august', 'august'], ['september', 'september'], ['october', 'october'], ['november', 'november'], ['december', 'december'],
|
|
91
|
+
['jan', 'january'], ['feb', 'february'], ['mar', 'march'], ['apr', 'april'], ['jun', 'june'], ['jul', 'july'], ['aug', 'august'],
|
|
92
|
+
['sep', 'september'], ['sept', 'september'], ['oct', 'october'], ['nov', 'november'], ['dec', 'december']])
|
|
93
|
+
/** Words that are dates only when capitalized: "may" is a verb, "march" a verb, "sun" and "wed" words. */
|
|
94
|
+
const DATE_NEEDS_CAPITAL = new Set(['may', 'march', 'mar', 'mon', 'tue', 'tues', 'wed', 'thu', 'thur', 'thurs', 'fri', 'sat', 'sun',
|
|
95
|
+
'jan', 'feb', 'apr', 'jun', 'jul', 'aug', 'sep', 'sept', 'oct', 'nov', 'dec'])
|
|
96
|
+
|
|
97
|
+
const NOT_NAMES = new Set(['i', "i'm", "i'll", "i've", "i'd", 'ok', 'okay', 'oh', 'o', 'a', 'am', 'pm'])
|
|
98
|
+
|
|
99
|
+
/** Correction markers, as token sequences (", no," is "no" between pause marks, checked below). */
|
|
100
|
+
const MARKERS: string[][] = [['actually'], ['i', 'mean'], ['i', 'meant'], ['no', 'wait'], ['wait', 'no'], ['wait'], ['sorry'],
|
|
101
|
+
['or', 'rather'], ['make', 'that'], ['scratch', 'that'], ['never', 'mind'], ['nevermind'], ['correction']]
|
|
102
|
+
/** Marker phrases whose words are not negations, on either side. */
|
|
103
|
+
const NEG_MARKER_PHRASES: string[][] = [['no', 'wait'], ['wait', 'no'], ['never', 'mind'], ['nevermind']]
|
|
104
|
+
/** One-word markers that are also ordinary words: a marker only when set off (see markers()). */
|
|
105
|
+
const BARE_ACTUALLY = 'actually'
|
|
106
|
+
const BARE_WAIT = 'wait'
|
|
107
|
+
const DROP_TO_MARKER = 8
|
|
108
|
+
const MARKER_TO_REPLACEMENT = 6
|
|
109
|
+
/** A bare "actually" (no punctuation around it) excuses a drop only with a replacement this close. */
|
|
110
|
+
const BARE_MARKER_TO_REPLACEMENT = 3
|
|
111
|
+
/** The speaker asked for a list, so line-start list numbers are formatting, not added numbers. */
|
|
112
|
+
const LIST_ASKED = /\b(?:numbered list|number one|bullet(?:ed)? (?:list|points?)|bullets)\b/i
|
|
113
|
+
|
|
114
|
+
const ENGLISH = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'to', 'of', 'in', 'on', 'for', 'with', 'is', 'are', 'was', 'were', 'be', 'it', 'this',
|
|
115
|
+
'that', 'i', 'you', 'we', 'they', 'he', 'she', 'my', 'our', 'your', 'their', 'can', 'will', 'do', 'does', 'not', 'at', 'from', 'have', 'has',
|
|
116
|
+
'me', 'us', 'so', 'if', 'what', 'just', 'about', 'there', 'here', 'all', 'some', 'any', 'how', 'why', 'when', 'need', 'want', 'get', 'make'])
|
|
117
|
+
|
|
118
|
+
const HOUR = '(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|\\d{1,2})'
|
|
119
|
+
const HALF_PAST = new RegExp(`\\bhalf past ${HOUR}\\b`, 'gi')
|
|
120
|
+
const QUARTER_PAST = new RegExp(`\\b(?:a )?quarter (?:past|after) ${HOUR}\\b`, 'gi')
|
|
121
|
+
const QUARTER_TO = new RegExp(`\\b(?:a )?quarter (?:to|of|till|til) ${HOUR}\\b`, 'gi')
|
|
122
|
+
|
|
123
|
+
/** An hour from 1 to 12, as a word or digits, or null. */
|
|
124
|
+
function hourOf(word: string): number | null {
|
|
125
|
+
const value = ONES.get(word.toLowerCase()) ?? (/^\d+$/.test(word) ? Number.parseInt(word, 10) : -1)
|
|
126
|
+
return value >= 1 && value <= 12 ? value : null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** "half past ten" is 10:30, "quarter past ten" 10:15, "quarter to five" 4:45 (and "quarter to one" 12:45). */
|
|
130
|
+
function spokenTimes(text: string): string {
|
|
131
|
+
return text
|
|
132
|
+
.replace(HALF_PAST, (all, h: string) => { const hour = hourOf(h); return hour === null ? all : `${hour}:30` })
|
|
133
|
+
.replace(QUARTER_PAST, (all, h: string) => { const hour = hourOf(h); return hour === null ? all : `${hour}:15` })
|
|
134
|
+
.replace(QUARTER_TO, (all, h: string) => { const hour = hourOf(h); return hour === null ? all : `${hour === 1 ? 12 : hour - 1}:45` })
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function normalize(text: string): string {
|
|
138
|
+
return spokenTimes(text)
|
|
139
|
+
.replace(/[’‘ʼ]/g, "'")
|
|
140
|
+
.replace(/\b([ap])\.\s?m\b\.?/gi, '$1m')
|
|
141
|
+
.replace(/(\d)\s?(am|pm)\b/gi, '$1 $2')
|
|
142
|
+
// "three o'clock" and "3:00" are one time.
|
|
143
|
+
.replace(/\s?o'?clock\b/gi, ':00')
|
|
144
|
+
// A thousands separator is not a split: "1,000" is 1000.
|
|
145
|
+
.replace(/(\d),(?=\d{3}\b)/g, '$1')
|
|
146
|
+
.replace(/(\d)k\b/gi, '$1 k')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function tokenize(text: string): Tok[] {
|
|
150
|
+
const out: Tok[] = []
|
|
151
|
+
const re = /[\p{L}\p{N}]+(?:'[\p{L}]+)*|[%$€£¢]/gu
|
|
152
|
+
let m: RegExpExecArray | null
|
|
153
|
+
let last = 0
|
|
154
|
+
while ((m = re.exec(text))) {
|
|
155
|
+
const gap = text.slice(last, m.index)
|
|
156
|
+
const before = text.slice(0, m.index).replace(/[\s"'“”()[\]{}*_-]+$/u, '')
|
|
157
|
+
const sentenceStart = before.length === 0 || /[.!?:\n•]$/.test(before) || /\n\s*(?:[-*•]|\d+[.)])?\s*$/.test(text.slice(0, m.index))
|
|
158
|
+
// "1." or "2)" at the start of a line: a list number.
|
|
159
|
+
const listMarker = /^\d+$/.test(m[0]) && /(?:^|\n)[ \t]*$/.test(text.slice(0, m.index)) && /^[.)]/.test(text.slice(m.index + m[0].length))
|
|
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 })
|
|
162
|
+
last = m.index + m[0].length
|
|
163
|
+
}
|
|
164
|
+
return out
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const fold = (w: string) => {
|
|
168
|
+
let x = w.replace(/'s$/, '')
|
|
169
|
+
if (x.length > 3 && /[^s]s$/.test(x)) x = x.slice(0, -1)
|
|
170
|
+
return x
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function startsWith(tokens: Tok[], at: number, phrase: string[]): boolean {
|
|
174
|
+
return phrase.every((w, i) => tokens[at + i]?.low === w)
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** A correction marker: where it starts and ends, how close its replacement must follow, and whether it stood alone. */
|
|
178
|
+
interface Span { start: number; end: number; reach: number; bare: boolean }
|
|
179
|
+
|
|
180
|
+
/** The correction markers, and the positions of the ones whose own words are not negations. */
|
|
181
|
+
function markers(tokens: Tok[]): { negCover: Set<number>; spans: Span[] } {
|
|
182
|
+
const negCover = new Set<number>()
|
|
183
|
+
const spans: Span[] = []
|
|
184
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
185
|
+
const phrase = MARKERS.find(p => startsWith(tokens, i, p))
|
|
186
|
+
if (phrase) {
|
|
187
|
+
// "actually" and "wait" alone are ordinary words unless punctuation sets them off
|
|
188
|
+
// ("Priya, actually Dana"; "Tuesday wait, Wednesday"). A bare "actually" still counts when the
|
|
189
|
+
// replacement follows within BARE_MARKER_TO_REPLACEMENT words ("five actually six"); a bare
|
|
190
|
+
// "wait" never does ("tell Marco to wait for Dana" keeps Marco).
|
|
191
|
+
const bare = phrase.length === 1 && (phrase[0] === BARE_ACTUALLY || phrase[0] === BARE_WAIT)
|
|
192
|
+
&& !tokens[i].pause && !tokens[i + 1]?.pause
|
|
193
|
+
if (bare && phrase[0] === BARE_WAIT) continue
|
|
194
|
+
spans.push({ start: i, end: i + phrase.length - 1, reach: bare ? BARE_MARKER_TO_REPLACEMENT : MARKER_TO_REPLACEMENT, bare })
|
|
195
|
+
if (NEG_MARKER_PHRASES.some(p => p.length === phrase.length && startsWith(tokens, i, p))) for (let k = 0; k < phrase.length; k++) negCover.add(i + k)
|
|
196
|
+
i += phrase.length - 1
|
|
197
|
+
continue
|
|
198
|
+
}
|
|
199
|
+
// ", no," : a "no" set off by pause marks on both sides is a correction ("the blue, no, the green").
|
|
200
|
+
if (tokens[i].low === 'no' && i > 0 && tokens[i].pause && tokens[i + 1]?.pause) {
|
|
201
|
+
spans.push({ start: i, end: i, reach: MARKER_TO_REPLACEMENT, bare: false }); negCover.add(i)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return { negCover, spans }
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Spoken numbers to digits: "twenty five" is 25, "twenty twenty six" is 20 then 26. An ordinal
|
|
209
|
+
* ends a run: "twenty fifth" is 25 (as "25th" is), "one hundred and first" 101.
|
|
210
|
+
*/
|
|
211
|
+
function numberRun(tokens: Tok[], i: number): { value: number; end: number } | null {
|
|
212
|
+
const first = tokens[i].low
|
|
213
|
+
if (!ONES.has(first) && !TENS.has(first) && !(SCALES.has(first) && first !== 'k')) return null
|
|
214
|
+
let total = 0, group = 0, last: 'ones' | 'tens' | 'scale' | null = null, teen = false, j = i
|
|
215
|
+
for (; j < tokens.length; j++) {
|
|
216
|
+
const w = tokens[j].low
|
|
217
|
+
const next = tokens[j + 1]?.low ?? ''
|
|
218
|
+
if (w === 'and' && last === 'scale' && (ONES.has(next) || TENS.has(next) || ORDINALS.has(next))) continue
|
|
219
|
+
if (ORDINALS.has(w)) {
|
|
220
|
+
const v = ORDINALS.get(w)!
|
|
221
|
+
if (!((last === 'tens' && v < 10) || last === 'scale')) break
|
|
222
|
+
group += v
|
|
223
|
+
j++
|
|
224
|
+
break
|
|
225
|
+
}
|
|
226
|
+
if (TENS.has(w)) {
|
|
227
|
+
if (last === 'ones' || last === 'tens') break
|
|
228
|
+
group += TENS.get(w)!; last = 'tens'
|
|
229
|
+
} else if (ONES.has(w)) {
|
|
230
|
+
const v = ONES.get(w)!
|
|
231
|
+
if (last === 'ones' || (last === 'tens' && (v >= 10 || v === 0)) || (teen && last !== 'scale')) break
|
|
232
|
+
group += v; last = 'ones'; if (v >= 10) teen = true
|
|
233
|
+
} else if (SCALES.has(w) && !(w === 'k' && last === null)) {
|
|
234
|
+
const s = SCALES.get(w)!
|
|
235
|
+
if (s === 100) group = (group || 1) * 100
|
|
236
|
+
else { total += (group || 1) * s; group = 0 }
|
|
237
|
+
last = 'scale'; teen = false
|
|
238
|
+
} else break
|
|
239
|
+
}
|
|
240
|
+
return { value: total + group, end: j - 1 }
|
|
241
|
+
}
|
|
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
|
+
|
|
311
|
+
interface Extract { items: Item[]; tokens: Tok[] }
|
|
312
|
+
|
|
313
|
+
function extract(text: string, knownPhrases: string[][], withNames: boolean): Extract {
|
|
314
|
+
const tokens = tokenize(normalize(text))
|
|
315
|
+
const { negCover } = markers(tokens)
|
|
316
|
+
const items: Item[] = []
|
|
317
|
+
const midCapital = new Set(tokens.filter(t => !t.sentenceStart && /\p{Lu}/u.test(t.raw)).map(t => fold(t.low)))
|
|
318
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
319
|
+
const t = tokens[i]
|
|
320
|
+
// Negations.
|
|
321
|
+
if (!negCover.has(i)) {
|
|
322
|
+
if (t.low === 'not' || t.low === 'cannot' || /n't$/.test(t.low) || BARE_NT.has(t.low)) items.push({ kind: 'negation', key: 'NEG', pos: i })
|
|
323
|
+
else if (NEG_WORDS.has(t.low)) items.push({ kind: 'negation', key: t.low, pos: i })
|
|
324
|
+
}
|
|
325
|
+
// Numbers.
|
|
326
|
+
if (/^\d+$/.test(t.low)) {
|
|
327
|
+
let value = Number.parseInt(t.low, 10)
|
|
328
|
+
const pos = i
|
|
329
|
+
// A digit and a scale word are one number: "2 million" is two million, as "two million" is.
|
|
330
|
+
while (SCALES.has(tokens[i + 1]?.low ?? '')) { value *= SCALES.get(tokens[i + 1].low)!; i++ }
|
|
331
|
+
items.push({ kind: 'number', key: String(value), pos, ...(t.listMarker ? { list: true } : {}) })
|
|
332
|
+
continue
|
|
333
|
+
}
|
|
334
|
+
const suffixed = /^(\d+)(st|nd|rd|th)$/.exec(t.low)
|
|
335
|
+
if (suffixed) { items.push({ kind: 'number', key: String(Number.parseInt(suffixed[1], 10)), pos: i }); continue }
|
|
336
|
+
// Digits inside a word ("Jewel360", "Q4", "iTerm2") are numbers too, so "jewel three sixty"
|
|
337
|
+
// written "Jewel360" keeps its 3 and 60 (joined), and the word is still checked as a name below.
|
|
338
|
+
if (/\p{L}/u.test(t.low) && /\d/.test(t.low)) {
|
|
339
|
+
for (const digits of t.low.match(/\d+/g) ?? []) items.push({ kind: 'number', key: String(Number.parseInt(digits, 10)), pos: i })
|
|
340
|
+
}
|
|
341
|
+
const run = numberRun(tokens, i)
|
|
342
|
+
if (run) {
|
|
343
|
+
items.push({ kind: 'number', key: String(run.value), pos: i })
|
|
344
|
+
i = run.end
|
|
345
|
+
continue
|
|
346
|
+
}
|
|
347
|
+
if (ORDINALS.has(t.low)) { items.push({ kind: 'number', key: String(ORDINALS.get(t.low)), pos: i }); continue }
|
|
348
|
+
if (NUMBER_WORDS.has(t.low)) { items.push({ kind: 'number', key: NUMBER_WORDS.get(t.low)!, pos: i }); continue }
|
|
349
|
+
if (SYMBOLS.has(t.raw)) { items.push({ kind: 'number', key: SYMBOLS.get(t.raw)!, pos: i }); continue }
|
|
350
|
+
if ((t.low === 'am' || t.low === 'pm') && i > 0 && (/^\d/.test(tokens[i - 1].low) || ONES.has(tokens[i - 1].low) || TENS.has(tokens[i - 1].low))) {
|
|
351
|
+
items.push({ kind: 'number', key: t.low, pos: i }); continue
|
|
352
|
+
}
|
|
353
|
+
// Dates.
|
|
354
|
+
const date = WEEKDAYS.get(t.low) ?? MONTHS.get(t.low)
|
|
355
|
+
if (date && (!DATE_NEEDS_CAPITAL.has(t.low) || /^\p{Lu}/u.test(t.raw))) { items.push({ kind: 'date', key: date, pos: i }); continue }
|
|
356
|
+
// Names: a mid-sentence capital (or a sentence-initial one the text capitalizes elsewhere too).
|
|
357
|
+
if (withNames && /\p{Lu}/u.test(t.raw) && t.raw.length >= 2 && !NOT_NAMES.has(t.low) && !ONES.has(t.low) && !TENS.has(t.low)
|
|
358
|
+
&& !SCALES.has(t.low) && !ORDINALS.has(t.low) && !NUMBER_WORDS.has(t.low) && !NEG_WORDS.has(t.low)) {
|
|
359
|
+
if (!t.sentenceStart || midCapital.has(fold(t.low))) items.push({ kind: 'name', key: fold(t.low), pos: i })
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
addTimes(tokens, items)
|
|
363
|
+
// Dictionary terms and speaker names, in any case and position, as whole phrases.
|
|
364
|
+
if (withNames) {
|
|
365
|
+
for (const phrase of knownPhrases) {
|
|
366
|
+
for (let i = 0; i + phrase.length <= tokens.length; i++) {
|
|
367
|
+
if (phrase.every((w, k) => fold(tokens[i + k].low) === w)) items.push({ kind: 'name', key: phrase.join(' '), pos: i })
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
return { items, tokens }
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function levenshtein(a: string, b: string): number {
|
|
375
|
+
if (a === b) return 0
|
|
376
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
377
|
+
for (let i = 1; i <= a.length; i++) {
|
|
378
|
+
let prev = row[0]
|
|
379
|
+
row[0] = i
|
|
380
|
+
for (let j = 1; j <= b.length; j++) {
|
|
381
|
+
const tmp = row[j]
|
|
382
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
383
|
+
prev = tmp
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
return row[b.length]
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Mostly English? A short text is assumed to be; a long one with few English function words is not. */
|
|
390
|
+
export function looksEnglish(text: string): boolean {
|
|
391
|
+
const letters = text.match(/\p{L}/gu) ?? []
|
|
392
|
+
if (letters.length && letters.filter(ch => !/[A-Za-z]/.test(ch)).length / letters.length > 0.2) return false
|
|
393
|
+
const words = tokenize(text).map(t => t.low)
|
|
394
|
+
// A short command ("ask Marco about the deck") has few function words; only a longer text can tell.
|
|
395
|
+
if (words.length < 12) return true
|
|
396
|
+
return words.filter(w => ENGLISH.has(w)).length / words.length >= 0.12
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const zero = (): Record<MeaningGuardKind, number> => ({ negation: 0, number: 0, date: 0, name: 0 })
|
|
400
|
+
|
|
401
|
+
/** How many of each negation, a stutter (the same one twice in a row) counted once. */
|
|
402
|
+
function negationCounts(items: Item[]): Map<string, number> {
|
|
403
|
+
const counts = new Map<string, number>()
|
|
404
|
+
let previous: Item | null = null
|
|
405
|
+
for (const item of items.filter(i => i.kind === 'negation').sort((a, b) => a.pos - b.pos)) {
|
|
406
|
+
if (!(previous && previous.key === item.key && item.pos === previous.pos + 1)) counts.set(item.key, (counts.get(item.key) ?? 0) + 1)
|
|
407
|
+
previous = item
|
|
408
|
+
}
|
|
409
|
+
return counts
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* The first kind the answer lost or added, with counts, or null when every guarded token the
|
|
414
|
+
* speaker kept is still there. `known` is the dictionary (vocabulary) and the speaker names.
|
|
415
|
+
*/
|
|
416
|
+
export function meaningGuardProblem(cleaned: string, answer: string, known: string[] = []): MeaningGuardProblem | null {
|
|
417
|
+
if (typeof cleaned !== 'string' || typeof answer !== 'string') return null
|
|
418
|
+
const knownPhrases = known.filter(k => typeof k === 'string' && k.trim())
|
|
419
|
+
.map(k => tokenize(normalize(k)).map(t => fold(t.low))).filter(p => p.length > 0)
|
|
420
|
+
const withNames = looksEnglish(cleaned)
|
|
421
|
+
const input = extract(cleaned, knownPhrases, withNames)
|
|
422
|
+
const output = extract(answer, knownPhrases, withNames)
|
|
423
|
+
const outKeys = new Map<MeaningGuardKind, Set<string>>(MEANING_GUARD_KINDS.map(k => [k, new Set(output.items.filter(i => i.kind === k).map(i => i.key))]))
|
|
424
|
+
const inKeys = new Map<MeaningGuardKind, Set<string>>(MEANING_GUARD_KINDS.map(k => [k, new Set(input.items.filter(i => i.kind === k).map(i => i.key))]))
|
|
425
|
+
// A bare "actually" is a marker only with a guarded word right after it ("five actually six");
|
|
426
|
+
// "I actually think" is an ordinary adverb, so it neither excuses a drop nor stops the counts.
|
|
427
|
+
const spans = markers(input.tokens).spans
|
|
428
|
+
.filter(span => !span.bare || input.items.some(r => r.kind !== 'negation' && r.pos > span.end && r.pos - span.end <= span.reach))
|
|
429
|
+
|
|
430
|
+
// Spoken digits joined in the answer ("twenty twenty six" as "2026", "nineteen ninety" as "1990").
|
|
431
|
+
const inNumbers = input.items.filter(i => i.kind === 'number' && /^\d+$/.test(i.key)).sort((a, b) => a.pos - b.pos)
|
|
432
|
+
const joined = new Map<string, string[]>()
|
|
433
|
+
for (let i = 0; i < inNumbers.length; i++) {
|
|
434
|
+
for (const len of [2, 3]) {
|
|
435
|
+
const group = inNumbers.slice(i, i + len)
|
|
436
|
+
if (group.length === len && group.every((g, k) => k === 0 || g.pos - group[k - 1].pos <= 2)) joined.set(group.map(g => g.key).join(''), group.map(g => g.key))
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
const outNumbers = outKeys.get('number')!
|
|
440
|
+
const keptByJoin = new Set<string>()
|
|
441
|
+
const joinedOut = new Set<string>()
|
|
442
|
+
for (const key of outNumbers) {
|
|
443
|
+
const parts = joined.get(key)
|
|
444
|
+
if (parts && !inKeys.get('number')!.has(key)) { parts.forEach(p => keptByJoin.add(p)); joinedOut.add(key) }
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Names the answer re-spelled: to a dictionary term, or to a near form it was asked to fix.
|
|
448
|
+
const outTokens = output.tokens
|
|
449
|
+
const inTokenSet = new Set(input.tokens.map(t => fold(t.low)))
|
|
450
|
+
const outJoined = new Set<string>()
|
|
451
|
+
for (let i = 0; i < input.tokens.length; i++) {
|
|
452
|
+
for (const len of [2, 3]) if (i + len <= input.tokens.length) outJoined.add(input.tokens.slice(i, i + len).map(t => fold(t.low).replace(/'/g, '')).join(''))
|
|
453
|
+
}
|
|
454
|
+
const nameKept = (key: string): boolean => {
|
|
455
|
+
if (outKeys.get('name')!.has(key)) return true
|
|
456
|
+
// Folded on both sides: a plural fold turns "bottlepos" into "bottlepo" on the answer's side too.
|
|
457
|
+
const outWords = [...new Set(outTokens.map(t => fold(t.low).replace(/'/g, '')))]
|
|
458
|
+
const flat = key.replace(/[' ]/g, '')
|
|
459
|
+
if (outWords.includes(fold(flat))) return true
|
|
460
|
+
// Joined into one word ("Hub Spot" as "HubSpot").
|
|
461
|
+
if (outWords.some(w => outJoined.has(w) && w.includes(flat))) return true
|
|
462
|
+
const knownFlat = knownPhrases.map(p => p.join(''))
|
|
463
|
+
const respelled = outTokens.some(t => {
|
|
464
|
+
const w = fold(t.low)
|
|
465
|
+
if (inTokenSet.has(w)) return false
|
|
466
|
+
if (knownFlat.includes(w)) return levenshtein(flat, w) <= Math.max(2, Math.floor(flat.length / 2))
|
|
467
|
+
return /\p{Lu}/u.test(t.raw) && levenshtein(flat, w) <= Math.max(1, Math.floor(flat.length / 4))
|
|
468
|
+
})
|
|
469
|
+
return respelled
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// 6.91.1: clock times, compared as minutes since midnight (see the header).
|
|
473
|
+
const inTimes = input.items.filter(i => i.time).map(i => i.time!)
|
|
474
|
+
const outTimes = output.items.filter(i => i.time).map(i => i.time!)
|
|
475
|
+
const bareHours = (items: Item[]) => items.filter(i => i.kind === 'number' && !i.time && !i.inTime && /^\d+$/.test(i.key)).map(i => Number(i.key))
|
|
476
|
+
const inBare = bareHours(input.items)
|
|
477
|
+
const outBare = bareHours(output.items)
|
|
478
|
+
const sameTime = (a: TimeSpan, b: TimeSpan) => a.times.some(t => b.times.includes(t))
|
|
479
|
+
// A bare hour matches a time only on that hour: its readings are all on the hour.
|
|
480
|
+
const onTheHour = (time: TimeSpan, n: number) => hourReadings(n).some(t => time.times.includes(t))
|
|
481
|
+
const numberKept = (key: string) => outKeys.get('number')!.has(key) || keptByJoin.has(key)
|
|
482
|
+
const timeKept = (time: TimeSpan) => outTimes.some(o => sameTime(time, o))
|
|
483
|
+
|| (!time.explicit && outBare.some(n => onTheHour(time, n)))
|
|
484
|
+
|| time.parts.every(numberKept)
|
|
485
|
+
// A time in the answer is not new when the dictation had it, had its hour ("at ten" as "10:00 am"),
|
|
486
|
+
// or had its parts as numbers. am, pm and ":00" are format only.
|
|
487
|
+
const timeAccounted = (time: TimeSpan) => inTimes.some(i => sameTime(i, time))
|
|
488
|
+
|| inBare.some(n => onTheHour(time, n))
|
|
489
|
+
|| time.parts.filter(p => !MERIDIEM.has(p) && p !== '0').every(p => inKeys.get('number')!.has(p))
|
|
490
|
+
const kept = (item: Item): boolean => item.kind === 'name' ? nameKept(item.key)
|
|
491
|
+
: item.time ? timeKept(item.time)
|
|
492
|
+
: item.kind === 'number' ? numberKept(item.key) || (/^\d+$/.test(item.key) && outTimes.some(o => onTheHour(o, Number(item.key))))
|
|
493
|
+
: outKeys.get(item.kind)!.has(item.key)
|
|
494
|
+
|
|
495
|
+
// A dropped token with a correction marker after it and a same-kind replacement the answer kept.
|
|
496
|
+
const exempt = (item: Item): boolean => {
|
|
497
|
+
if (item.kind === 'negation') return false
|
|
498
|
+
for (const { start, end, reach } of spans) {
|
|
499
|
+
if (start <= item.pos || start - item.pos > DROP_TO_MARKER) continue
|
|
500
|
+
const replaced = input.items.some(r => r.kind === item.kind && r.key !== item.key && r.pos > end && r.pos - end <= reach && kept(r))
|
|
501
|
+
if (replaced) return true
|
|
502
|
+
}
|
|
503
|
+
return false
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// With no correction marker, negations are counted, so losing one of two "not"s trips. A
|
|
507
|
+
// correction can legitimately drop a repeated negation ("can't, no wait, can't do Friday"), so
|
|
508
|
+
// with a marker they are compared as sets, like every other kind.
|
|
509
|
+
const countNegations = spans.length === 0
|
|
510
|
+
|
|
511
|
+
const dropped = zero()
|
|
512
|
+
const added = zero()
|
|
513
|
+
const seen = new Set<string>()
|
|
514
|
+
for (const item of input.items) {
|
|
515
|
+
const id = `${item.kind}:${item.key}`
|
|
516
|
+
if (seen.has(id)) continue
|
|
517
|
+
if (item.kind === 'negation' && countNegations) continue
|
|
518
|
+
// A number inside a clock time is judged with its time.
|
|
519
|
+
if (item.inTime) continue
|
|
520
|
+
if (kept(item)) { seen.add(id); continue }
|
|
521
|
+
// Every occurrence must be exempt for a drop to be excused.
|
|
522
|
+
const occurrences = input.items.filter(i => i.kind === item.kind && i.key === item.key)
|
|
523
|
+
seen.add(id)
|
|
524
|
+
if (occurrences.every(exempt)) continue
|
|
525
|
+
dropped[item.kind]++
|
|
526
|
+
}
|
|
527
|
+
if (countNegations) {
|
|
528
|
+
const inCounts = negationCounts(input.items)
|
|
529
|
+
const outCounts = negationCounts(output.items)
|
|
530
|
+
for (const [key, n] of inCounts) if ((outCounts.get(key) ?? 0) < n) dropped.negation += n - (outCounts.get(key) ?? 0)
|
|
531
|
+
for (const [key, n] of outCounts) if ((inCounts.get(key) ?? 0) < n) added.negation += n - (inCounts.get(key) ?? 0)
|
|
532
|
+
} else {
|
|
533
|
+
for (const key of outKeys.get('negation')!) if (!inKeys.get('negation')!.has(key)) added.negation++
|
|
534
|
+
}
|
|
535
|
+
// A list the speaker asked for: its line-start numbers are formatting, never added numbers.
|
|
536
|
+
const listOnly = new Set<string>()
|
|
537
|
+
if (LIST_ASKED.test(cleaned)) {
|
|
538
|
+
const outNumberItems = output.items.filter(i => i.kind === 'number')
|
|
539
|
+
for (const key of outNumbers) if (outNumberItems.filter(i => i.key === key).every(i => i.list)) listOnly.add(key)
|
|
540
|
+
}
|
|
541
|
+
const plainOut = new Set(output.items.filter(i => i.kind === 'number' && !i.time && !i.inTime).map(i => i.key))
|
|
542
|
+
for (const key of plainOut) if (!inKeys.get('number')!.has(key) && !joinedOut.has(key) && !listOnly.has(key)) added.number++
|
|
543
|
+
for (const time of outTimes) if (!timeAccounted(time)) added.number++
|
|
544
|
+
|
|
545
|
+
const kind = MEANING_GUARD_KINDS.find(k => dropped[k] > 0 || added[k] > 0)
|
|
546
|
+
return kind ? { kind, dropped, added, namesSkipped: !withNames } : null
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/** "negation=0/1 number=..." (dropped/added) for the log: counts only, never a word. */
|
|
550
|
+
export function meaningGuardCounts(problem: MeaningGuardProblem): string {
|
|
551
|
+
return MEANING_GUARD_KINDS.map(k => `${k}=${problem.dropped[k]}/${problem.added[k]}`).join(' ')
|
|
552
|
+
}
|
|
@@ -26,9 +26,9 @@ export const CUSTOM_INSTRUCTIONS_PROTOCOL = 1
|
|
|
26
26
|
/**
|
|
27
27
|
* The prompt's own delimiters. A preference that contains one could close its block early and
|
|
28
28
|
* write text the model would read as COS's, so every one of them is removed, in any case and
|
|
29
|
-
* with any spacing or attributes.
|
|
29
|
+
* with any spacing or attributes. 6.91.0: the learned-style block's tag too.
|
|
30
30
|
*/
|
|
31
|
-
const RESERVED_TAG = /<\s*\/?\s*(?:user-style-preferences|known-spellings|dictation)\b[^>]*>/gi
|
|
31
|
+
const RESERVED_TAG = /<\s*\/?\s*(?:user-style-preferences|learned-style|known-spellings|dictation)\b[^>]*>/gi
|
|
32
32
|
/** C0 and C1 controls other than line feed and tab, plus DEL. */
|
|
33
33
|
const CONTROL = /[\u0000-\u0008\u000B-\u001F\u007F-\u009F]/g
|
|
34
34
|
/** Invisible format characters: zero-width, bidi embeddings, overrides and isolates, BOM, soft hyphen. */
|