@gotcos/glasses-server 6.89.0 → 6.91.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 +28 -0
- package/CHANGELOG.md +182 -0
- package/README.md +66 -4
- package/managed-runtime-contract.json +6 -1
- package/package.json +3 -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 +449 -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/mac-voice-labels.ts +50 -0
- package/server/lib/meeting-batch-transcribe.ts +9 -3
- package/server/lib/meeting-relabel-enrolment.ts +12 -1
- package/server/lib/meeting-relabel.ts +8 -1
- package/server/lib/meeting-speaker-review.ts +4 -1
- package/server/lib/meeting-store.ts +3 -1
- package/server/lib/nemotron-final.ts +314 -7
- package/server/lib/nemotron-live.ts +3 -1
- package/server/lib/speaker-embeddings.ts +39 -1
- package/server/lib/tailscale-nudge.ts +8 -4
- 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
- package/server/routes/transcribe-stream.ts +13 -2
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto'
|
|
2
|
+
import { readFileSync, statSync, unlinkSync } from 'node:fs'
|
|
3
|
+
import { atomicWriteFileSync } from './atomic-fs.js'
|
|
4
|
+
import { dataPath } from './data-dir.js'
|
|
5
|
+
import { readSwitch } from './env-switch.js'
|
|
6
|
+
import type { DictationRegister } from './dictation-register.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* 6.91.0: "Your style", the learned-style setting (plan r2, contract frozen 2026-10-24).
|
|
10
|
+
*
|
|
11
|
+
* COS Control derives style rules from the corrections the person types into their own dictation
|
|
12
|
+
* (a deterministic diff on the Mac, no LLM) and stores the active ones here, so Mac dictation and
|
|
13
|
+
* G2 messages both get them. The same pattern as the 6.85.0 polish instructions, with its own file,
|
|
14
|
+
* cap and switch, and one difference that matters: the client sends CODES, never text. A rule is
|
|
15
|
+
* a `kind` plus a `param` from a closed set, and the server renders the words the model reads
|
|
16
|
+
* from its own templates, so no client string ever reaches the prompt.
|
|
17
|
+
*
|
|
18
|
+
* - kinds: `drop_phrase` (param from LEARNED_STYLE_ALLOWLIST) and `contractions` (use | avoid).
|
|
19
|
+
* - register: `all` only in the MVP. A literal target (a terminal) never gets learned style.
|
|
20
|
+
* - At most LEARNED_STYLE_MAX_RULES rules, rendered to at most LEARNED_STYLE_MAX_CHARS. Over either
|
|
21
|
+
* is refused (413), never silently dropped or turned off.
|
|
22
|
+
* - The server owns `text`, `hash`, `revision` and `updatedAt`. `revision` is 8 hex of the rendered
|
|
23
|
+
* text AND the person's switch, and `baseRevision` makes a PUT conditional (409), so a stale save
|
|
24
|
+
* can undo neither a newer rule list nor a newer Off. `hash` (the text alone) is what finalize
|
|
25
|
+
* answers and telemetry carry.
|
|
26
|
+
* - `enabled: false` (the person's switch) stops it applying everywhere and keeps the rules. Reset
|
|
27
|
+
* (DELETE) clears the rules and leaves the switch as it was.
|
|
28
|
+
* COS_DICTATION_LEARNED_STYLE=0 (the server's switch) does the same, whatever the file says.
|
|
29
|
+
* - Stored atomically in the data dir, mode 0600, re-validated on every read; a corrupt file reads
|
|
30
|
+
* as empty and is logged as a code only. The rules are never logged; telemetry carries a flag
|
|
31
|
+
* and an 8-hex hash. Never for meeting lines.
|
|
32
|
+
*/
|
|
33
|
+
export const LEARNED_STYLE_PROTOCOL = 1
|
|
34
|
+
export const LEARNED_STYLE_MAX_CHARS = 1_500
|
|
35
|
+
export const LEARNED_STYLE_MAX_RULES = 20
|
|
36
|
+
export const LEARNED_STYLE_FILE = 'dictation-learned-style.json'
|
|
37
|
+
|
|
38
|
+
export const LEARNED_STYLE_KINDS = ['drop_phrase', 'contractions'] as const
|
|
39
|
+
export type LearnedStyleKind = typeof LEARNED_STYLE_KINDS[number]
|
|
40
|
+
export const LEARNED_STYLE_REGISTERS = ['all'] as const
|
|
41
|
+
export type LearnedStyleRegister = typeof LEARNED_STYLE_REGISTERS[number]
|
|
42
|
+
export const CONTRACTION_PARAMS = ['use', 'avoid'] as const
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The closed list of phrases a drop_phrase rule may name. Never a negation, a scope word, a number,
|
|
46
|
+
* one of the built-in polish fillers (um, uh, like, you know, I mean, sort of, kind of, basically,
|
|
47
|
+
* so, well), or a word that often carries meaning (like, so, just, right, actually, really).
|
|
48
|
+
*/
|
|
49
|
+
export const LEARNED_STYLE_ALLOWLIST = [
|
|
50
|
+
'honestly', 'literally', 'pretty much', 'i guess', 'to be honest', 'frankly', 'obviously',
|
|
51
|
+
'kinda', 'sorta', 'you see', 'at the end of the day', 'if that makes sense',
|
|
52
|
+
] as const
|
|
53
|
+
|
|
54
|
+
export interface LearnedStyleRule {
|
|
55
|
+
id: string
|
|
56
|
+
kind: LearnedStyleKind
|
|
57
|
+
register: LearnedStyleRegister
|
|
58
|
+
param: string
|
|
59
|
+
count: number
|
|
60
|
+
lastSeen: string | null
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function learnedStyleEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
64
|
+
return readSwitch(env.COS_DICTATION_LEARNED_STYLE, true)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The one place the words come from: a template per kind, filled with a closed-set param. */
|
|
68
|
+
export function ruleText(rule: Pick<LearnedStyleRule, 'kind' | 'param'>): string {
|
|
69
|
+
if (rule.kind === 'drop_phrase') return `Drop "${rule.param}" where the speaker used it as filler.`
|
|
70
|
+
return rule.param === 'use'
|
|
71
|
+
? 'Use contractions where they fit ("I am" becomes "I\'m", "do not" becomes "don\'t").'
|
|
72
|
+
: 'Write contractions out in full ("I\'m" becomes "I am", "don\'t" becomes "do not").'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The stored, shown and hashed text: one "- " line per rule, from the templates only. */
|
|
76
|
+
export function renderLearnedStyle(rules: Array<Pick<LearnedStyleRule, 'kind' | 'param'>>): string {
|
|
77
|
+
return rules.map(rule => `- ${ruleText(rule)}`).join('\n')
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 8 hex characters: enough to tell two settings apart in a log, never the rules. '' for none. */
|
|
81
|
+
export function learnedStyleHash(text: string): string {
|
|
82
|
+
return text ? createHash('sha256').update(text, 'utf8').digest('hex').slice(0, 8) : ''
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 8 hex of the rendered text and the switch: what a conditional PUT names as its base. Always 8. */
|
|
86
|
+
export function learnedStyleRevision(text: string, enabled: boolean): string {
|
|
87
|
+
return createHash('sha256').update(`${enabled ? 'on' : 'off'}\n${text}`, 'utf8').digest('hex').slice(0, 8)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const RULE_ID = /^[a-z0-9_-]{1,40}$/
|
|
91
|
+
const RULE_KEYS = new Set(['id', 'kind', 'register', 'param', 'count', 'lastSeen'])
|
|
92
|
+
|
|
93
|
+
export type RulesResult =
|
|
94
|
+
| { ok: true; rules: LearnedStyleRule[] }
|
|
95
|
+
| { ok: false; status: 400 | 413; error: string; index?: number; max?: number }
|
|
96
|
+
|
|
97
|
+
/** Validate rules exactly; one bad rule refuses the whole list. Pure. */
|
|
98
|
+
export function validateLearnedStyleRules(raw: unknown): RulesResult {
|
|
99
|
+
if (!Array.isArray(raw)) return { ok: false, status: 400, error: 'rules_not_an_array' }
|
|
100
|
+
if (raw.length > LEARNED_STYLE_MAX_RULES) return { ok: false, status: 413, error: 'too_many_rules', max: LEARNED_STYLE_MAX_RULES }
|
|
101
|
+
const rules: LearnedStyleRule[] = []
|
|
102
|
+
const ids = new Set<string>()
|
|
103
|
+
const meanings = new Map<string, string>()
|
|
104
|
+
for (let index = 0; index < raw.length; index++) {
|
|
105
|
+
const rule = raw[index]
|
|
106
|
+
if (!rule || typeof rule !== 'object' || Array.isArray(rule) || !Object.keys(rule).every(key => RULE_KEYS.has(key))) {
|
|
107
|
+
return { ok: false, status: 400, error: 'invalid_rule', index }
|
|
108
|
+
}
|
|
109
|
+
const { id, kind, register, param, count, lastSeen } = rule as Record<string, unknown>
|
|
110
|
+
if (typeof id !== 'string' || !RULE_ID.test(id) || ids.has(id)) return { ok: false, status: 400, error: 'invalid_rule_id', index }
|
|
111
|
+
if (typeof kind !== 'string' || !(LEARNED_STYLE_KINDS as readonly string[]).includes(kind)) return { ok: false, status: 400, error: 'invalid_rule_kind', index }
|
|
112
|
+
if (typeof register !== 'string' || !(LEARNED_STYLE_REGISTERS as readonly string[]).includes(register)) {
|
|
113
|
+
return { ok: false, status: 400, error: 'invalid_rule_register', index }
|
|
114
|
+
}
|
|
115
|
+
const allowed: readonly string[] = kind === 'drop_phrase' ? LEARNED_STYLE_ALLOWLIST : CONTRACTION_PARAMS
|
|
116
|
+
if (typeof param !== 'string' || !allowed.includes(param)) return { ok: false, status: 400, error: 'invalid_rule_param', index }
|
|
117
|
+
const n = count ?? 0
|
|
118
|
+
if (typeof n !== 'number' || !Number.isInteger(n) || n < 0 || n > 1_000_000) return { ok: false, status: 400, error: 'invalid_rule_count', index }
|
|
119
|
+
let seen: string | null = null
|
|
120
|
+
if (lastSeen !== undefined && lastSeen !== null) {
|
|
121
|
+
if (typeof lastSeen !== 'string' || lastSeen.length > 40 || Number.isNaN(Date.parse(lastSeen))) {
|
|
122
|
+
return { ok: false, status: 400, error: 'invalid_rule_last_seen', index }
|
|
123
|
+
}
|
|
124
|
+
seen = new Date(Date.parse(lastSeen)).toISOString()
|
|
125
|
+
}
|
|
126
|
+
// One rule per meaning: two drops of one phrase, or "use" and "avoid" together, is a client bug.
|
|
127
|
+
const meaning = kind === 'drop_phrase' ? `drop:${param}` : 'contractions'
|
|
128
|
+
if (meanings.has(meaning)) return { ok: false, status: 400, error: meanings.get(meaning) === param ? 'duplicate_rule' : 'conflicting_rules', index }
|
|
129
|
+
meanings.set(meaning, param)
|
|
130
|
+
ids.add(id)
|
|
131
|
+
rules.push({ id, kind: kind as LearnedStyleKind, register: register as LearnedStyleRegister, param, count: n, lastSeen: seen })
|
|
132
|
+
}
|
|
133
|
+
if (renderLearnedStyle(rules).length > LEARNED_STYLE_MAX_CHARS) return { ok: false, status: 413, error: 'learned_style_too_long', max: LEARNED_STYLE_MAX_CHARS }
|
|
134
|
+
return { ok: true, rules }
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The lines one polish gets, or '' when nothing applies: the person switched it off, the target
|
|
139
|
+
* is literal (a terminal never restyles), or there are no rules. Every rule is `all` in the MVP.
|
|
140
|
+
*/
|
|
141
|
+
export function learnedStyleLinesFor(stored: StoredLearnedStyle, register: DictationRegister): string {
|
|
142
|
+
if (!stored.enabled || register === 'literal' || !stored.rules.length) return ''
|
|
143
|
+
return renderLearnedStyle(stored.rules.filter(rule => rule.register === 'all'))
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const filePath = () => dataPath(LEARNED_STYLE_FILE)
|
|
147
|
+
|
|
148
|
+
export interface StoredLearnedStyle {
|
|
149
|
+
enabled: boolean; rules: LearnedStyleRule[]; text: string; hash: string; revision: string; updatedAt: string | null
|
|
150
|
+
}
|
|
151
|
+
const empty = (): StoredLearnedStyle => ({ enabled: true, rules: [], text: '', hash: '', revision: learnedStyleRevision('', true), updatedAt: null })
|
|
152
|
+
|
|
153
|
+
let cache: { mtimeMs: number; size: number; value: StoredLearnedStyle } | null = null
|
|
154
|
+
let warned = false
|
|
155
|
+
|
|
156
|
+
function warnOnce(code: string): void {
|
|
157
|
+
if (warned) return
|
|
158
|
+
warned = true
|
|
159
|
+
// A code only. The file's contents are the person's style and are never logged.
|
|
160
|
+
console.warn(`[dictation-polish] learned style unreadable (${code}); polish runs without it until it is saved again`)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const copy = (value: StoredLearnedStyle): StoredLearnedStyle => ({ ...value, rules: value.rules.map(rule => ({ ...rule })) })
|
|
164
|
+
|
|
165
|
+
function build(enabled: boolean, rules: LearnedStyleRule[], updatedAt: string | null): StoredLearnedStyle {
|
|
166
|
+
const text = renderLearnedStyle(rules)
|
|
167
|
+
return { enabled, rules, text, hash: learnedStyleHash(text), revision: learnedStyleRevision(text, enabled), updatedAt }
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The stored setting, validated again on every read. Missing or corrupt reads as empty. mtime-cached. */
|
|
171
|
+
export function readLearnedStyle(): StoredLearnedStyle {
|
|
172
|
+
let stat: { mtimeMs: number; size: number }
|
|
173
|
+
try { const s = statSync(filePath()); stat = { mtimeMs: s.mtimeMs, size: s.size } } catch { cache = null; return empty() }
|
|
174
|
+
if (cache && cache.mtimeMs === stat.mtimeMs && cache.size === stat.size) return copy(cache.value)
|
|
175
|
+
let value = empty()
|
|
176
|
+
try {
|
|
177
|
+
const parsed = JSON.parse(readFileSync(filePath(), 'utf8'))
|
|
178
|
+
const clean = validateLearnedStyleRules(parsed?.rules)
|
|
179
|
+
if (clean.ok && (parsed.enabled === undefined || typeof parsed.enabled === 'boolean')) {
|
|
180
|
+
value = build(parsed.enabled !== false, clean.rules, typeof parsed.updatedAt === 'string' ? parsed.updatedAt : null)
|
|
181
|
+
} else {
|
|
182
|
+
warnOnce(clean.ok ? 'enabled' : clean.error)
|
|
183
|
+
}
|
|
184
|
+
} catch {
|
|
185
|
+
warnOnce('parse')
|
|
186
|
+
}
|
|
187
|
+
cache = { ...stat, value }
|
|
188
|
+
return copy(value)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Save validated rules. No rules and enabled is the empty state, which deletes the file. */
|
|
192
|
+
export function writeLearnedStyle(enabled: boolean, rules: LearnedStyleRule[], now: Date = new Date()): StoredLearnedStyle {
|
|
193
|
+
cache = null
|
|
194
|
+
if (!rules.length && enabled) {
|
|
195
|
+
try { unlinkSync(filePath()) } catch (err: any) { if (err?.code !== 'ENOENT') throw err }
|
|
196
|
+
return empty()
|
|
197
|
+
}
|
|
198
|
+
const stored = build(enabled, rules, now.toISOString())
|
|
199
|
+
atomicWriteFileSync(filePath(), JSON.stringify({ version: LEARNED_STYLE_PROTOCOL, enabled, rules, updatedAt: stored.updatedAt }, null, 2) + '\n', { mode: 0o600 })
|
|
200
|
+
return copy(stored)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function __resetLearnedStyleCacheForTests(): void { cache = null; warned = false }
|
|
@@ -0,0 +1,449 @@
|
|
|
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"), clock times ("half past ten" is
|
|
15
|
+
* 10:30, "quarter to five" 4:45) and am/pm. A decimal point splits a number on both sides ("one
|
|
16
|
+
* point five" and "1.5" are both 1 then 5);
|
|
17
|
+
* - date: a weekday or a month;
|
|
18
|
+
* - name: a mid-sentence capital, a dictionary term or a speaker name. A sentence-initial word and
|
|
19
|
+
* "I" are not names unless they are dictionary terms. Skipped for a dictation that is not English.
|
|
20
|
+
*
|
|
21
|
+
* Normalization: case-insensitive; split on - / @ . and every other mark; number words become
|
|
22
|
+
* digits ("twenty twenty six" may come back as "2026"); possessives and plurals fold; a name
|
|
23
|
+
* re-spelled to a dictionary term (or to a near form the model was asked to fix) counts as kept.
|
|
24
|
+
*
|
|
25
|
+
* It trips on a DROPPED token of any kind, and on an ADDED negation or number. With no correction
|
|
26
|
+
* marker in the dictation, negations are COUNTED ("not saying we should not ship" losing one "not"
|
|
27
|
+
* trips; a stutter, the same negation twice in a row, counts once); with one, they are compared as
|
|
28
|
+
* sets. A dropped token is exempt only when a correction marker follows it and a token of the SAME
|
|
29
|
+
* kind replaces it within a few words ("five, actually six"; "Priya ... actually Dana"; "Tuesday,
|
|
30
|
+
* no wait, Wednesday"). "actually" alone is a marker only when punctuation sets it off or the
|
|
31
|
+
* replacement follows within 3 words; "wait" alone only when punctuation sets it off, because it is
|
|
32
|
+
* an ordinary verb ("tell Marco to wait for Dana"). A negation is never exempt; only a marker
|
|
33
|
+
* phrase's own words ("no wait", "wait no", ", no,", "never mind") are not negations. When the
|
|
34
|
+
* speaker asked for a list ("numbered list", "number one", "bullet points"), a list number at the
|
|
35
|
+
* start of a line ("1." or "2)") is never an added number. Known gaps, stated and not claimed:
|
|
36
|
+
* reordering, before/after/only, and a list number standing in for a dropped number of the same
|
|
37
|
+
* value. Pure; it never throws on strings and logs nothing.
|
|
38
|
+
*/
|
|
39
|
+
export type MeaningGuardKind = 'negation' | 'number' | 'date' | 'name'
|
|
40
|
+
export const MEANING_GUARD_KINDS: readonly MeaningGuardKind[] = ['negation', 'number', 'date', 'name']
|
|
41
|
+
|
|
42
|
+
export interface MeaningGuardProblem {
|
|
43
|
+
kind: MeaningGuardKind
|
|
44
|
+
dropped: Record<MeaningGuardKind, number>
|
|
45
|
+
added: Record<MeaningGuardKind, number>
|
|
46
|
+
/** True when the name check was skipped because the dictation is not English. */
|
|
47
|
+
namesSkipped: boolean
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function meaningGuardEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
51
|
+
const raw = (env.COS_DICTATION_MEANING_GUARD ?? '').trim().toLowerCase()
|
|
52
|
+
return !(raw === '0' || raw === 'false' || raw === 'off')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface Tok { raw: string; low: string; sentenceStart: boolean; pause: boolean; listMarker: boolean }
|
|
56
|
+
interface Item { kind: MeaningGuardKind; key: string; pos: number; list?: boolean }
|
|
57
|
+
|
|
58
|
+
const ONES = new Map(['zero', 'one', 'two', 'three', 'four', 'five', 'six', 'seven', 'eight', 'nine', 'ten', 'eleven', 'twelve',
|
|
59
|
+
'thirteen', 'fourteen', 'fifteen', 'sixteen', 'seventeen', 'eighteen', 'nineteen'].map((w, i) => [w, i]))
|
|
60
|
+
const TENS = new Map([['twenty', 20], ['thirty', 30], ['forty', 40], ['fourty', 40], ['fifty', 50], ['sixty', 60], ['seventy', 70], ['eighty', 80], ['ninety', 90]])
|
|
61
|
+
const SCALES = new Map([['hundred', 100], ['thousand', 1_000], ['k', 1_000], ['million', 1_000_000], ['billion', 1_000_000_000]])
|
|
62
|
+
const ORDINALS = new Map([['first', 1], ['second', 2], ['third', 3], ['fourth', 4], ['fifth', 5], ['sixth', 6], ['seventh', 7], ['eighth', 8],
|
|
63
|
+
['ninth', 9], ['tenth', 10], ['eleventh', 11], ['twelfth', 12], ['thirteenth', 13], ['fourteenth', 14], ['fifteenth', 15],
|
|
64
|
+
['sixteenth', 16], ['seventeenth', 17], ['eighteenth', 18], ['nineteenth', 19], ['twentieth', 20], ['thirtieth', 30], ['fortieth', 40],
|
|
65
|
+
['fiftieth', 50], ['sixtieth', 60], ['seventieth', 70], ['eightieth', 80], ['ninetieth', 90]])
|
|
66
|
+
/** Unit and time words that are part of a number item. */
|
|
67
|
+
const NUMBER_WORDS = new Map([['percent', 'percent'], ['percentage', 'percent'], ['dollar', 'dollar'], ['dollars', 'dollar'], ['buck', 'dollar'],
|
|
68
|
+
['bucks', 'dollar'], ['euro', 'euro'], ['euros', 'euro'], ['pound', 'pound'], ['pounds', 'pound'], ['cent', 'cent'], ['cents', 'cent'],
|
|
69
|
+
['noon', 'noon'], ['midnight', 'midnight']])
|
|
70
|
+
const SYMBOLS = new Map([['%', 'percent'], ['$', 'dollar'], ['€', 'euro'], ['£', 'pound'], ['¢', 'cent']])
|
|
71
|
+
|
|
72
|
+
const NEG_WORDS = new Set(['never', 'no', 'none', 'nothing', 'nobody', 'neither', 'nor', 'without', 'unless', 'except'])
|
|
73
|
+
/** "n't" forms speech-to-text writes without the apostrophe: the same NEG token as "don't". */
|
|
74
|
+
const BARE_NT = new Set(['dont', 'cant', 'wont', 'isnt', 'arent', 'doesnt', 'didnt', 'wasnt', 'werent', 'shouldnt', 'wouldnt', 'couldnt',
|
|
75
|
+
'havent', 'hasnt', 'hadnt', 'mustnt', 'neednt', 'aint'])
|
|
76
|
+
|
|
77
|
+
const WEEKDAYS = new Map([['monday', 'monday'], ['tuesday', 'tuesday'], ['wednesday', 'wednesday'], ['thursday', 'thursday'], ['friday', 'friday'],
|
|
78
|
+
['saturday', 'saturday'], ['sunday', 'sunday'], ['mon', 'monday'], ['tue', 'tuesday'], ['tues', 'tuesday'], ['wed', 'wednesday'],
|
|
79
|
+
['thu', 'thursday'], ['thur', 'thursday'], ['thurs', 'thursday'], ['fri', 'friday'], ['sat', 'saturday'], ['sun', 'sunday']])
|
|
80
|
+
const MONTHS = new Map([['january', 'january'], ['february', 'february'], ['march', 'march'], ['april', 'april'], ['may', 'may'], ['june', 'june'],
|
|
81
|
+
['july', 'july'], ['august', 'august'], ['september', 'september'], ['october', 'october'], ['november', 'november'], ['december', 'december'],
|
|
82
|
+
['jan', 'january'], ['feb', 'february'], ['mar', 'march'], ['apr', 'april'], ['jun', 'june'], ['jul', 'july'], ['aug', 'august'],
|
|
83
|
+
['sep', 'september'], ['sept', 'september'], ['oct', 'october'], ['nov', 'november'], ['dec', 'december']])
|
|
84
|
+
/** Words that are dates only when capitalized: "may" is a verb, "march" a verb, "sun" and "wed" words. */
|
|
85
|
+
const DATE_NEEDS_CAPITAL = new Set(['may', 'march', 'mar', 'mon', 'tue', 'tues', 'wed', 'thu', 'thur', 'thurs', 'fri', 'sat', 'sun',
|
|
86
|
+
'jan', 'feb', 'apr', 'jun', 'jul', 'aug', 'sep', 'sept', 'oct', 'nov', 'dec'])
|
|
87
|
+
|
|
88
|
+
const NOT_NAMES = new Set(['i', "i'm", "i'll", "i've", "i'd", 'ok', 'okay', 'oh', 'o', 'a', 'am', 'pm'])
|
|
89
|
+
|
|
90
|
+
/** Correction markers, as token sequences (", no," is "no" between pause marks, checked below). */
|
|
91
|
+
const MARKERS: string[][] = [['actually'], ['i', 'mean'], ['i', 'meant'], ['no', 'wait'], ['wait', 'no'], ['wait'], ['sorry'],
|
|
92
|
+
['or', 'rather'], ['make', 'that'], ['scratch', 'that'], ['never', 'mind'], ['nevermind'], ['correction']]
|
|
93
|
+
/** Marker phrases whose words are not negations, on either side. */
|
|
94
|
+
const NEG_MARKER_PHRASES: string[][] = [['no', 'wait'], ['wait', 'no'], ['never', 'mind'], ['nevermind']]
|
|
95
|
+
/** One-word markers that are also ordinary words: a marker only when set off (see markers()). */
|
|
96
|
+
const BARE_ACTUALLY = 'actually'
|
|
97
|
+
const BARE_WAIT = 'wait'
|
|
98
|
+
const DROP_TO_MARKER = 8
|
|
99
|
+
const MARKER_TO_REPLACEMENT = 6
|
|
100
|
+
/** A bare "actually" (no punctuation around it) excuses a drop only with a replacement this close. */
|
|
101
|
+
const BARE_MARKER_TO_REPLACEMENT = 3
|
|
102
|
+
/** The speaker asked for a list, so line-start list numbers are formatting, not added numbers. */
|
|
103
|
+
const LIST_ASKED = /\b(?:numbered list|number one|bullet(?:ed)? (?:list|points?)|bullets)\b/i
|
|
104
|
+
|
|
105
|
+
const ENGLISH = new Set(['the', 'a', 'an', 'and', 'or', 'but', 'to', 'of', 'in', 'on', 'for', 'with', 'is', 'are', 'was', 'were', 'be', 'it', 'this',
|
|
106
|
+
'that', 'i', 'you', 'we', 'they', 'he', 'she', 'my', 'our', 'your', 'their', 'can', 'will', 'do', 'does', 'not', 'at', 'from', 'have', 'has',
|
|
107
|
+
'me', 'us', 'so', 'if', 'what', 'just', 'about', 'there', 'here', 'all', 'some', 'any', 'how', 'why', 'when', 'need', 'want', 'get', 'make'])
|
|
108
|
+
|
|
109
|
+
const HOUR = '(one|two|three|four|five|six|seven|eight|nine|ten|eleven|twelve|\\d{1,2})'
|
|
110
|
+
const HALF_PAST = new RegExp(`\\bhalf past ${HOUR}\\b`, 'gi')
|
|
111
|
+
const QUARTER_PAST = new RegExp(`\\b(?:a )?quarter (?:past|after) ${HOUR}\\b`, 'gi')
|
|
112
|
+
const QUARTER_TO = new RegExp(`\\b(?:a )?quarter (?:to|of|till|til) ${HOUR}\\b`, 'gi')
|
|
113
|
+
|
|
114
|
+
/** An hour from 1 to 12, as a word or digits, or null. */
|
|
115
|
+
function hourOf(word: string): number | null {
|
|
116
|
+
const value = ONES.get(word.toLowerCase()) ?? (/^\d+$/.test(word) ? Number.parseInt(word, 10) : -1)
|
|
117
|
+
return value >= 1 && value <= 12 ? value : null
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** "half past ten" is 10:30, "quarter past ten" 10:15, "quarter to five" 4:45 (and "quarter to one" 12:45). */
|
|
121
|
+
function spokenTimes(text: string): string {
|
|
122
|
+
return text
|
|
123
|
+
.replace(HALF_PAST, (all, h: string) => { const hour = hourOf(h); return hour === null ? all : `${hour}:30` })
|
|
124
|
+
.replace(QUARTER_PAST, (all, h: string) => { const hour = hourOf(h); return hour === null ? all : `${hour}:15` })
|
|
125
|
+
.replace(QUARTER_TO, (all, h: string) => { const hour = hourOf(h); return hour === null ? all : `${hour === 1 ? 12 : hour - 1}:45` })
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalize(text: string): string {
|
|
129
|
+
return spokenTimes(text)
|
|
130
|
+
.replace(/[’‘ʼ]/g, "'")
|
|
131
|
+
.replace(/\b([ap])\.\s?m\b\.?/gi, '$1m')
|
|
132
|
+
.replace(/(\d)\s?(am|pm)\b/gi, '$1 $2')
|
|
133
|
+
// "three o'clock" and "3:00" are one time.
|
|
134
|
+
.replace(/\s?o'?clock\b/gi, ':00')
|
|
135
|
+
// A thousands separator is not a split: "1,000" is 1000.
|
|
136
|
+
.replace(/(\d),(?=\d{3}\b)/g, '$1')
|
|
137
|
+
.replace(/(\d)k\b/gi, '$1 k')
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function tokenize(text: string): Tok[] {
|
|
141
|
+
const out: Tok[] = []
|
|
142
|
+
const re = /[\p{L}\p{N}]+(?:'[\p{L}]+)*|[%$€£¢]/gu
|
|
143
|
+
let m: RegExpExecArray | null
|
|
144
|
+
let last = 0
|
|
145
|
+
while ((m = re.exec(text))) {
|
|
146
|
+
const gap = text.slice(last, m.index)
|
|
147
|
+
const before = text.slice(0, m.index).replace(/[\s"'“”()[\]{}*_-]+$/u, '')
|
|
148
|
+
const sentenceStart = before.length === 0 || /[.!?:\n•]$/.test(before) || /\n\s*(?:[-*•]|\d+[.)])?\s*$/.test(text.slice(0, m.index))
|
|
149
|
+
// "1." or "2)" at the start of a line: a list number.
|
|
150
|
+
const listMarker = /^\d+$/.test(m[0]) && /(?:^|\n)[ \t]*$/.test(text.slice(0, m.index)) && /^[.)]/.test(text.slice(m.index + m[0].length))
|
|
151
|
+
out.push({ raw: m[0], low: m[0].toLowerCase(), sentenceStart, pause: /[,;.!?—–()]/.test(gap), listMarker })
|
|
152
|
+
last = m.index + m[0].length
|
|
153
|
+
}
|
|
154
|
+
return out
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const fold = (w: string) => {
|
|
158
|
+
let x = w.replace(/'s$/, '')
|
|
159
|
+
if (x.length > 3 && /[^s]s$/.test(x)) x = x.slice(0, -1)
|
|
160
|
+
return x
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function startsWith(tokens: Tok[], at: number, phrase: string[]): boolean {
|
|
164
|
+
return phrase.every((w, i) => tokens[at + i]?.low === w)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A correction marker: where it starts and ends, how close its replacement must follow, and whether it stood alone. */
|
|
168
|
+
interface Span { start: number; end: number; reach: number; bare: boolean }
|
|
169
|
+
|
|
170
|
+
/** The correction markers, and the positions of the ones whose own words are not negations. */
|
|
171
|
+
function markers(tokens: Tok[]): { negCover: Set<number>; spans: Span[] } {
|
|
172
|
+
const negCover = new Set<number>()
|
|
173
|
+
const spans: Span[] = []
|
|
174
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
175
|
+
const phrase = MARKERS.find(p => startsWith(tokens, i, p))
|
|
176
|
+
if (phrase) {
|
|
177
|
+
// "actually" and "wait" alone are ordinary words unless punctuation sets them off
|
|
178
|
+
// ("Priya, actually Dana"; "Tuesday wait, Wednesday"). A bare "actually" still counts when the
|
|
179
|
+
// replacement follows within BARE_MARKER_TO_REPLACEMENT words ("five actually six"); a bare
|
|
180
|
+
// "wait" never does ("tell Marco to wait for Dana" keeps Marco).
|
|
181
|
+
const bare = phrase.length === 1 && (phrase[0] === BARE_ACTUALLY || phrase[0] === BARE_WAIT)
|
|
182
|
+
&& !tokens[i].pause && !tokens[i + 1]?.pause
|
|
183
|
+
if (bare && phrase[0] === BARE_WAIT) continue
|
|
184
|
+
spans.push({ start: i, end: i + phrase.length - 1, reach: bare ? BARE_MARKER_TO_REPLACEMENT : MARKER_TO_REPLACEMENT, bare })
|
|
185
|
+
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)
|
|
186
|
+
i += phrase.length - 1
|
|
187
|
+
continue
|
|
188
|
+
}
|
|
189
|
+
// ", no," : a "no" set off by pause marks on both sides is a correction ("the blue, no, the green").
|
|
190
|
+
if (tokens[i].low === 'no' && i > 0 && tokens[i].pause && tokens[i + 1]?.pause) {
|
|
191
|
+
spans.push({ start: i, end: i, reach: MARKER_TO_REPLACEMENT, bare: false }); negCover.add(i)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return { negCover, spans }
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Spoken numbers to digits: "twenty five" is 25, "twenty twenty six" is 20 then 26. An ordinal
|
|
199
|
+
* ends a run: "twenty fifth" is 25 (as "25th" is), "one hundred and first" 101.
|
|
200
|
+
*/
|
|
201
|
+
function numberRun(tokens: Tok[], i: number): { value: number; end: number } | null {
|
|
202
|
+
const first = tokens[i].low
|
|
203
|
+
if (!ONES.has(first) && !TENS.has(first) && !(SCALES.has(first) && first !== 'k')) return null
|
|
204
|
+
let total = 0, group = 0, last: 'ones' | 'tens' | 'scale' | null = null, teen = false, j = i
|
|
205
|
+
for (; j < tokens.length; j++) {
|
|
206
|
+
const w = tokens[j].low
|
|
207
|
+
const next = tokens[j + 1]?.low ?? ''
|
|
208
|
+
if (w === 'and' && last === 'scale' && (ONES.has(next) || TENS.has(next) || ORDINALS.has(next))) continue
|
|
209
|
+
if (ORDINALS.has(w)) {
|
|
210
|
+
const v = ORDINALS.get(w)!
|
|
211
|
+
if (!((last === 'tens' && v < 10) || last === 'scale')) break
|
|
212
|
+
group += v
|
|
213
|
+
j++
|
|
214
|
+
break
|
|
215
|
+
}
|
|
216
|
+
if (TENS.has(w)) {
|
|
217
|
+
if (last === 'ones' || last === 'tens') break
|
|
218
|
+
group += TENS.get(w)!; last = 'tens'
|
|
219
|
+
} else if (ONES.has(w)) {
|
|
220
|
+
const v = ONES.get(w)!
|
|
221
|
+
if (last === 'ones' || (last === 'tens' && (v >= 10 || v === 0)) || (teen && last !== 'scale')) break
|
|
222
|
+
group += v; last = 'ones'; if (v >= 10) teen = true
|
|
223
|
+
} else if (SCALES.has(w) && !(w === 'k' && last === null)) {
|
|
224
|
+
const s = SCALES.get(w)!
|
|
225
|
+
if (s === 100) group = (group || 1) * 100
|
|
226
|
+
else { total += (group || 1) * s; group = 0 }
|
|
227
|
+
last = 'scale'; teen = false
|
|
228
|
+
} else break
|
|
229
|
+
}
|
|
230
|
+
return { value: total + group, end: j - 1 }
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
interface Extract { items: Item[]; tokens: Tok[] }
|
|
234
|
+
|
|
235
|
+
function extract(text: string, knownPhrases: string[][], withNames: boolean): Extract {
|
|
236
|
+
const tokens = tokenize(normalize(text))
|
|
237
|
+
const { negCover } = markers(tokens)
|
|
238
|
+
const items: Item[] = []
|
|
239
|
+
const midCapital = new Set(tokens.filter(t => !t.sentenceStart && /\p{Lu}/u.test(t.raw)).map(t => fold(t.low)))
|
|
240
|
+
for (let i = 0; i < tokens.length; i++) {
|
|
241
|
+
const t = tokens[i]
|
|
242
|
+
// Negations.
|
|
243
|
+
if (!negCover.has(i)) {
|
|
244
|
+
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 })
|
|
245
|
+
else if (NEG_WORDS.has(t.low)) items.push({ kind: 'negation', key: t.low, pos: i })
|
|
246
|
+
}
|
|
247
|
+
// Numbers.
|
|
248
|
+
if (/^\d+$/.test(t.low)) {
|
|
249
|
+
let value = Number.parseInt(t.low, 10)
|
|
250
|
+
const pos = i
|
|
251
|
+
// A digit and a scale word are one number: "2 million" is two million, as "two million" is.
|
|
252
|
+
while (SCALES.has(tokens[i + 1]?.low ?? '')) { value *= SCALES.get(tokens[i + 1].low)!; i++ }
|
|
253
|
+
items.push({ kind: 'number', key: String(value), pos, ...(t.listMarker ? { list: true } : {}) })
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
const suffixed = /^(\d+)(st|nd|rd|th)$/.exec(t.low)
|
|
257
|
+
if (suffixed) { items.push({ kind: 'number', key: String(Number.parseInt(suffixed[1], 10)), pos: i }); continue }
|
|
258
|
+
// Digits inside a word ("Jewel360", "Q4", "iTerm2") are numbers too, so "jewel three sixty"
|
|
259
|
+
// written "Jewel360" keeps its 3 and 60 (joined), and the word is still checked as a name below.
|
|
260
|
+
if (/\p{L}/u.test(t.low) && /\d/.test(t.low)) {
|
|
261
|
+
for (const digits of t.low.match(/\d+/g) ?? []) items.push({ kind: 'number', key: String(Number.parseInt(digits, 10)), pos: i })
|
|
262
|
+
}
|
|
263
|
+
const run = numberRun(tokens, i)
|
|
264
|
+
if (run) {
|
|
265
|
+
items.push({ kind: 'number', key: String(run.value), pos: i })
|
|
266
|
+
i = run.end
|
|
267
|
+
continue
|
|
268
|
+
}
|
|
269
|
+
if (ORDINALS.has(t.low)) { items.push({ kind: 'number', key: String(ORDINALS.get(t.low)), pos: i }); continue }
|
|
270
|
+
if (NUMBER_WORDS.has(t.low)) { items.push({ kind: 'number', key: NUMBER_WORDS.get(t.low)!, pos: i }); continue }
|
|
271
|
+
if (SYMBOLS.has(t.raw)) { items.push({ kind: 'number', key: SYMBOLS.get(t.raw)!, pos: i }); continue }
|
|
272
|
+
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))) {
|
|
273
|
+
items.push({ kind: 'number', key: t.low, pos: i }); continue
|
|
274
|
+
}
|
|
275
|
+
// Dates.
|
|
276
|
+
const date = WEEKDAYS.get(t.low) ?? MONTHS.get(t.low)
|
|
277
|
+
if (date && (!DATE_NEEDS_CAPITAL.has(t.low) || /^\p{Lu}/u.test(t.raw))) { items.push({ kind: 'date', key: date, pos: i }); continue }
|
|
278
|
+
// Names: a mid-sentence capital (or a sentence-initial one the text capitalizes elsewhere too).
|
|
279
|
+
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)
|
|
280
|
+
&& !SCALES.has(t.low) && !ORDINALS.has(t.low) && !NUMBER_WORDS.has(t.low) && !NEG_WORDS.has(t.low)) {
|
|
281
|
+
if (!t.sentenceStart || midCapital.has(fold(t.low))) items.push({ kind: 'name', key: fold(t.low), pos: i })
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
// Dictionary terms and speaker names, in any case and position, as whole phrases.
|
|
285
|
+
if (withNames) {
|
|
286
|
+
for (const phrase of knownPhrases) {
|
|
287
|
+
for (let i = 0; i + phrase.length <= tokens.length; i++) {
|
|
288
|
+
if (phrase.every((w, k) => fold(tokens[i + k].low) === w)) items.push({ kind: 'name', key: phrase.join(' '), pos: i })
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
return { items, tokens }
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function levenshtein(a: string, b: string): number {
|
|
296
|
+
if (a === b) return 0
|
|
297
|
+
const row = Array.from({ length: b.length + 1 }, (_, i) => i)
|
|
298
|
+
for (let i = 1; i <= a.length; i++) {
|
|
299
|
+
let prev = row[0]
|
|
300
|
+
row[0] = i
|
|
301
|
+
for (let j = 1; j <= b.length; j++) {
|
|
302
|
+
const tmp = row[j]
|
|
303
|
+
row[j] = Math.min(row[j] + 1, row[j - 1] + 1, prev + (a[i - 1] === b[j - 1] ? 0 : 1))
|
|
304
|
+
prev = tmp
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
return row[b.length]
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Mostly English? A short text is assumed to be; a long one with few English function words is not. */
|
|
311
|
+
export function looksEnglish(text: string): boolean {
|
|
312
|
+
const letters = text.match(/\p{L}/gu) ?? []
|
|
313
|
+
if (letters.length && letters.filter(ch => !/[A-Za-z]/.test(ch)).length / letters.length > 0.2) return false
|
|
314
|
+
const words = tokenize(text).map(t => t.low)
|
|
315
|
+
// A short command ("ask Marco about the deck") has few function words; only a longer text can tell.
|
|
316
|
+
if (words.length < 12) return true
|
|
317
|
+
return words.filter(w => ENGLISH.has(w)).length / words.length >= 0.12
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const zero = (): Record<MeaningGuardKind, number> => ({ negation: 0, number: 0, date: 0, name: 0 })
|
|
321
|
+
|
|
322
|
+
/** How many of each negation, a stutter (the same one twice in a row) counted once. */
|
|
323
|
+
function negationCounts(items: Item[]): Map<string, number> {
|
|
324
|
+
const counts = new Map<string, number>()
|
|
325
|
+
let previous: Item | null = null
|
|
326
|
+
for (const item of items.filter(i => i.kind === 'negation').sort((a, b) => a.pos - b.pos)) {
|
|
327
|
+
if (!(previous && previous.key === item.key && item.pos === previous.pos + 1)) counts.set(item.key, (counts.get(item.key) ?? 0) + 1)
|
|
328
|
+
previous = item
|
|
329
|
+
}
|
|
330
|
+
return counts
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The first kind the answer lost or added, with counts, or null when every guarded token the
|
|
335
|
+
* speaker kept is still there. `known` is the dictionary (vocabulary) and the speaker names.
|
|
336
|
+
*/
|
|
337
|
+
export function meaningGuardProblem(cleaned: string, answer: string, known: string[] = []): MeaningGuardProblem | null {
|
|
338
|
+
if (typeof cleaned !== 'string' || typeof answer !== 'string') return null
|
|
339
|
+
const knownPhrases = known.filter(k => typeof k === 'string' && k.trim())
|
|
340
|
+
.map(k => tokenize(normalize(k)).map(t => fold(t.low))).filter(p => p.length > 0)
|
|
341
|
+
const withNames = looksEnglish(cleaned)
|
|
342
|
+
const input = extract(cleaned, knownPhrases, withNames)
|
|
343
|
+
const output = extract(answer, knownPhrases, withNames)
|
|
344
|
+
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))]))
|
|
345
|
+
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))]))
|
|
346
|
+
// A bare "actually" is a marker only with a guarded word right after it ("five actually six");
|
|
347
|
+
// "I actually think" is an ordinary adverb, so it neither excuses a drop nor stops the counts.
|
|
348
|
+
const spans = markers(input.tokens).spans
|
|
349
|
+
.filter(span => !span.bare || input.items.some(r => r.kind !== 'negation' && r.pos > span.end && r.pos - span.end <= span.reach))
|
|
350
|
+
|
|
351
|
+
// Spoken digits joined in the answer ("twenty twenty six" as "2026", "nineteen ninety" as "1990").
|
|
352
|
+
const inNumbers = input.items.filter(i => i.kind === 'number' && /^\d+$/.test(i.key)).sort((a, b) => a.pos - b.pos)
|
|
353
|
+
const joined = new Map<string, string[]>()
|
|
354
|
+
for (let i = 0; i < inNumbers.length; i++) {
|
|
355
|
+
for (const len of [2, 3]) {
|
|
356
|
+
const group = inNumbers.slice(i, i + len)
|
|
357
|
+
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))
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
const outNumbers = outKeys.get('number')!
|
|
361
|
+
const keptByJoin = new Set<string>()
|
|
362
|
+
const joinedOut = new Set<string>()
|
|
363
|
+
for (const key of outNumbers) {
|
|
364
|
+
const parts = joined.get(key)
|
|
365
|
+
if (parts && !inKeys.get('number')!.has(key)) { parts.forEach(p => keptByJoin.add(p)); joinedOut.add(key) }
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Names the answer re-spelled: to a dictionary term, or to a near form it was asked to fix.
|
|
369
|
+
const outTokens = output.tokens
|
|
370
|
+
const inTokenSet = new Set(input.tokens.map(t => fold(t.low)))
|
|
371
|
+
const outJoined = new Set<string>()
|
|
372
|
+
for (let i = 0; i < input.tokens.length; i++) {
|
|
373
|
+
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(''))
|
|
374
|
+
}
|
|
375
|
+
const nameKept = (key: string): boolean => {
|
|
376
|
+
if (outKeys.get('name')!.has(key)) return true
|
|
377
|
+
// Folded on both sides: a plural fold turns "bottlepos" into "bottlepo" on the answer's side too.
|
|
378
|
+
const outWords = [...new Set(outTokens.map(t => fold(t.low).replace(/'/g, '')))]
|
|
379
|
+
const flat = key.replace(/[' ]/g, '')
|
|
380
|
+
if (outWords.includes(fold(flat))) return true
|
|
381
|
+
// Joined into one word ("Hub Spot" as "HubSpot").
|
|
382
|
+
if (outWords.some(w => outJoined.has(w) && w.includes(flat))) return true
|
|
383
|
+
const knownFlat = knownPhrases.map(p => p.join(''))
|
|
384
|
+
const respelled = outTokens.some(t => {
|
|
385
|
+
const w = fold(t.low)
|
|
386
|
+
if (inTokenSet.has(w)) return false
|
|
387
|
+
if (knownFlat.includes(w)) return levenshtein(flat, w) <= Math.max(2, Math.floor(flat.length / 2))
|
|
388
|
+
return /\p{Lu}/u.test(t.raw) && levenshtein(flat, w) <= Math.max(1, Math.floor(flat.length / 4))
|
|
389
|
+
})
|
|
390
|
+
return respelled
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// A dropped token with a correction marker after it and a same-kind replacement the answer kept.
|
|
394
|
+
const exempt = (item: Item): boolean => {
|
|
395
|
+
if (item.kind === 'negation') return false
|
|
396
|
+
for (const { start, end, reach } of spans) {
|
|
397
|
+
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)))
|
|
400
|
+
if (replaced) return true
|
|
401
|
+
}
|
|
402
|
+
return false
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// With no correction marker, negations are counted, so losing one of two "not"s trips. A
|
|
406
|
+
// correction can legitimately drop a repeated negation ("can't, no wait, can't do Friday"), so
|
|
407
|
+
// with a marker they are compared as sets, like every other kind.
|
|
408
|
+
const countNegations = spans.length === 0
|
|
409
|
+
|
|
410
|
+
const dropped = zero()
|
|
411
|
+
const added = zero()
|
|
412
|
+
const seen = new Set<string>()
|
|
413
|
+
for (const item of input.items) {
|
|
414
|
+
const id = `${item.kind}:${item.key}`
|
|
415
|
+
if (seen.has(id)) continue
|
|
416
|
+
if (item.kind === 'negation' && countNegations) continue
|
|
417
|
+
const kept = item.kind === 'name' ? nameKept(item.key)
|
|
418
|
+
: outKeys.get(item.kind)!.has(item.key) || (item.kind === 'number' && keptByJoin.has(item.key))
|
|
419
|
+
if (kept) { seen.add(id); continue }
|
|
420
|
+
// Every occurrence must be exempt for a drop to be excused.
|
|
421
|
+
const occurrences = input.items.filter(i => i.kind === item.kind && i.key === item.key)
|
|
422
|
+
seen.add(id)
|
|
423
|
+
if (occurrences.every(exempt)) continue
|
|
424
|
+
dropped[item.kind]++
|
|
425
|
+
}
|
|
426
|
+
if (countNegations) {
|
|
427
|
+
const inCounts = negationCounts(input.items)
|
|
428
|
+
const outCounts = negationCounts(output.items)
|
|
429
|
+
for (const [key, n] of inCounts) if ((outCounts.get(key) ?? 0) < n) dropped.negation += n - (outCounts.get(key) ?? 0)
|
|
430
|
+
for (const [key, n] of outCounts) if ((inCounts.get(key) ?? 0) < n) added.negation += n - (inCounts.get(key) ?? 0)
|
|
431
|
+
} else {
|
|
432
|
+
for (const key of outKeys.get('negation')!) if (!inKeys.get('negation')!.has(key)) added.negation++
|
|
433
|
+
}
|
|
434
|
+
// A list the speaker asked for: its line-start numbers are formatting, never added numbers.
|
|
435
|
+
const listOnly = new Set<string>()
|
|
436
|
+
if (LIST_ASKED.test(cleaned)) {
|
|
437
|
+
const outNumberItems = output.items.filter(i => i.kind === 'number')
|
|
438
|
+
for (const key of outNumbers) if (outNumberItems.filter(i => i.key === key).every(i => i.list)) listOnly.add(key)
|
|
439
|
+
}
|
|
440
|
+
for (const key of outNumbers) if (!inKeys.get('number')!.has(key) && !joinedOut.has(key) && !listOnly.has(key)) added.number++
|
|
441
|
+
|
|
442
|
+
const kind = MEANING_GUARD_KINDS.find(k => dropped[k] > 0 || added[k] > 0)
|
|
443
|
+
return kind ? { kind, dropped, added, namesSkipped: !withNames } : null
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** "negation=0/1 number=..." (dropped/added) for the log: counts only, never a word. */
|
|
447
|
+
export function meaningGuardCounts(problem: MeaningGuardProblem): string {
|
|
448
|
+
return MEANING_GUARD_KINDS.map(k => `${k}=${problem.dropped[k]}/${problem.added[k]}`).join(' ')
|
|
449
|
+
}
|