-
Notifications
You must be signed in to change notification settings - Fork 1
/
syntax.ts
411 lines (357 loc) · 18 KB
/
syntax.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
import {
Filter, Directive,
ParamMap, VarType, ParamValue,
} from './publicInterfaces';
import { FilterInterpretation, FilterInterpretations, DirectiveInterpretation, DirectiveInterpretations } from './moduleInterfaces';
import { matchWordsToPhrase, phoneticPhraseDistance } from './text';
import { wordsToParsedNumber } from './numeric';
import { isNullOrUndefined, isNumber } from 'util';
import { trimf } from './sort';
import { cloneDeep, flatten } from 'lodash';
// Don't filter anything
const passThruFilter: Filter =
(filteredInput: FilterInterpretations) => filteredInput
// Grabs a phrase of fixed word count
const phraseFilter = (wordCount: number, preFilter: Filter): Filter =>
(filteredInput: FilterInterpretations): FilterInterpretations => {
return preFilter(filteredInput).map((fInput: FilterInterpretation) => ({
...fInput,
words: fInput.words.splice(wordCount),
consumed: fInput.consumed + wordCount,
}))
}
// Grabs all words up to a given stop word
const stopPhraseFilter = (stopPhrases: string[], includeStopword: boolean, preFilter: Filter): Filter =>
(filteredInput: FilterInterpretations): FilterInterpretations => {
const possibleInterpretations: FilterInterpretations = []
preFilter(filteredInput).forEach((interpretation: FilterInterpretation) => {
let stopSeen = false
const fuzzyStopMatches: FilterInterpretations = []
for (let w = 1; w < interpretation.words.length && !stopSeen; w++) {
for (let s = 0; s < stopPhrases.length && !stopSeen; s++) {
const stopPhrase = stopPhrases[s]
const stopPhraseWords = stopPhrase.split(' ')
const stopMatch = matchWordsToPhrase(interpretation.words.slice(w), stopPhrase)
const includedStopwords: string[] = includeStopword ? stopPhraseWords : []
const selectedWords = interpretation.words.slice(0, w)
const distance = phoneticPhraseDistance(stopMatch.join(' '), stopPhrase)
const newInterpretation = {
...interpretation,
words: [...selectedWords, ...includedStopwords],
consumed: interpretation.consumed + selectedWords.length + stopMatch.length,
penalty: interpretation.penalty + distance,
}
// If exact match is hit, only assume the exact match
if (distance === 0) {
possibleInterpretations.push(newInterpretation)
stopSeen = true
break
}
fuzzyStopMatches.push(newInterpretation)
}
}
if (!stopSeen) {
possibleInterpretations.push(...fuzzyStopMatches)
}
})
return trimf(possibleInterpretations)
}
const remainingPhraseFilter = (preFilter: Filter): Filter =>
(filtered: FilterInterpretations): FilterInterpretations => {
return preFilter(filtered).map((interpretation: FilterInterpretation) => ({
...interpretation,
consumed: interpretation.words.length + interpretation.consumed,
}))
}
// ------ Boolean stuff -----------
interface FilterInterpretationMap {
[index: string]: FilterInterpretation,
}
const filterInterpretationToKey = (interpretation: FilterInterpretation): string => {
return `${interpretation.varType},${interpretation.consumed},${interpretation.words.join(' ')}`
}
const filterInterpretationsToMap = (interpretations: FilterInterpretations): FilterInterpretationMap => {
const interpretationMap: FilterInterpretationMap = {}
interpretations.forEach((interpretation: FilterInterpretation) => {
const key = filterInterpretationToKey(interpretation)
const curKeyPenalty = interpretationMap[key] === undefined ? Infinity : interpretationMap[key].penalty
if (interpretation.penalty < curKeyPenalty) {
interpretationMap[key] = interpretation
}
})
return interpretationMap
}
const mergeInterpretationMaps = (map1: FilterInterpretationMap, map2: FilterInterpretationMap): FilterInterpretationMap => {
const merged: FilterInterpretationMap = {}
const allKeys = [...Object.keys(map1), ...Object.keys(map2)]
for (const key of allKeys) {
if (map1[key] !== undefined && map2[key] !== undefined) {
merged[key] = {
...map1[key],
penalty: Math.min(map1[key].penalty, map2[key].penalty),
}
}
else {
merged[key] = map1[key] === undefined ? map2[key] : map1[key]
}
}
return merged
}
const intersectInterpretationMaps = (map1: FilterInterpretationMap, map2: FilterInterpretationMap): FilterInterpretationMap => {
const intersected: FilterInterpretationMap = {}
for (const key of Object.keys(map1)) {
if (map2[key] !== undefined) {
intersected[key] = {
...map1[key],
penalty: (map1[key].penalty + map2[key].penalty),
}
}
}
return intersected
}
// merge all filters' results together and trim by penalty
const orFilter = (filters: Filter[]): Filter =>
(filtered: FilterInterpretations): FilterInterpretations => {
let mergeList: FilterInterpretationMap = {}
const allResults = filters.map((filterI: Filter) => filterI(filtered))
allResults.forEach((result: FilterInterpretations) => {
mergeList = mergeInterpretationMaps(mergeList, filterInterpretationsToMap(result))
})
return trimf(Object.keys(mergeList).map((key: string) => mergeList[key]))
}
// merge together only the FilterInterpretations that are equal by matching
// words and type, as well as number of input words consumed
const andFilter = (filters: Filter[]): Filter =>
(filtered: FilterInterpretations): FilterInterpretations => {
let candidateInterpretations = {}
const allResults = filters.map((filterI: Filter) => filterI(filtered))
allResults.forEach((result: FilterInterpretations, i: number) => {
if (i === 0) {
candidateInterpretations = filterInterpretationsToMap(result)
}
else {
candidateInterpretations = intersectInterpretationMaps(
candidateInterpretations,
filterInterpretationsToMap(result))
}
})
return trimf(flatten(allResults))
}
// TO DO: fix the consequences of this guy's existence
/* const mapper = (phraseToReplacementMap: PhraseMap, preFilter: Filter): Filter =>
(filtered: FilterInterpretations): FilterInterpretations => {
return preFilter(filtered).map((interpretation: FilterInterpretation) => {
const { words } = interpretation
const mapped = replaceWords(phraseToReplacementMap, words)
return {
...interpretation,
words: mapped,
consumed: interpretation.consumed + (words.length - mapped.length),
}
});
} */
const anyFilter = (phraseWhitelist: string[], preFilter: Filter) =>
(filteredInput: FilterInterpretations): FilterInterpretations => {
return trimf(flatten(preFilter(filteredInput).map((interpretation: FilterInterpretation): FilterInterpretations => {
return phraseWhitelist.map((allowed: string) => {
const matchedWords = matchWordsToPhrase(interpretation.words, allowed)
const phrasePenalty = phoneticPhraseDistance(allowed, matchedWords.join(' '))
const allowedWords = allowed.split(' ')
return {
...interpretation,
words: allowedWords,
consumed: interpretation.consumed + matchedWords.length,
penalty: phrasePenalty + interpretation.penalty,
}
})
})))
};
const lazyAnyFilter = (phraseWhitelistGenerator: () => string[], preFilter: Filter) =>
(filteredInput: FilterInterpretations): FilterInterpretations => {
return anyFilter(phraseWhitelistGenerator(), preFilter)(filteredInput)
}
const blacklistWordCount = (phraseBlacklist: string[]): number => {
if (phraseBlacklist.length === 0) {
throw new Error('Cannot pass empty list to None')
}
const wordCount = phraseBlacklist[0].split(' ').length
phraseBlacklist.map((p: string) => {
if (p.split(' ').length !== wordCount) {
throw new Error('None\'s blacklisted phrases must all have equal word counts');
}
})
return wordCount
}
// This is an all-or-nothing filter, it won't penalize for sounding similar to blacklisted words
const noneFilter = (phraseBlacklist: string[], preFilter: Filter) => {
const wordCount = blacklistWordCount(phraseBlacklist);
return (filteredInput: FilterInterpretations): FilterInterpretations => {
return trimf(preFilter(filteredInput).map((interpretation: FilterInterpretation) => {
for (const disallowed of phraseBlacklist) {
const matchedWords = matchWordsToPhrase(interpretation.words, disallowed)
const distance = phoneticPhraseDistance(matchedWords.join(' '), disallowed)
if (distance === 0) {
return {
...interpretation,
words: [],
penalty: Infinity,
}
}
}
const withRemovals = interpretation.words.slice(0, wordCount)
return {
...interpretation,
words: withRemovals,
consumed: interpretation.consumed + wordCount,
}
}))
}
}
const lazyNoneFilter = (phraseBlacklistGenerator: () => string[], preFilter: Filter) =>
(filteredInput: FilterInterpretations): FilterInterpretations => {
return noneFilter(phraseBlacklistGenerator(), preFilter)(filteredInput)
}
const precisionFilter = (maxAllowablePenalty: number, preFilter: Filter) => {
return (filteredInput: FilterInterpretations): FilterInterpretations => {
return trimf(preFilter(filteredInput).filter((interpretation: FilterInterpretation) =>
interpretation.penalty <= maxAllowablePenalty,
))
}
}
const numericFilter = (minNumber: number, maxNumber: number, preFilter: Filter): Filter =>
(filteredInput: FilterInterpretations): FilterInterpretations => {
return preFilter(filteredInput).map((interpretation: FilterInterpretation) => {
const parsedNumber = wordsToParsedNumber(interpretation.words)
if (isFinite(parsedNumber.value) &&
!isNaN(parsedNumber.value) &&
parsedNumber.value >= minNumber &&
parsedNumber.value <= maxNumber) {
return {
...interpretation,
words: [`${parsedNumber.value}`],
consumed: (interpretation.consumed + parsedNumber.consumed),
varType: VarType.Numeric,
}
} else {
return {
...interpretation,
varType: VarType.Numeric,
penalty: Infinity,
words: [],
}
}
})
}
const paramValue = (interpretation: FilterInterpretation): ParamValue =>
interpretation.varType === VarType.Text ?
interpretation.words.join(' ') :
parseFloat(interpretation.words[0])
function updateParams<P extends ParamMap>(oldParams: P, name?: string, value?: ParamValue): P {
if (name !== undefined) {
return {
...(oldParams as object),
[name]: value,
} as P
}
return cloneDeep(oldParams)
}
// Require a filtered match to add to the run function's named parameter list
function varDirective<P extends ParamMap>(name: string | undefined, filter: Filter): Directive<P> {
return (words: string[], runParams: P, maxFuzzyFilterResults: number): DirectiveInterpretations<P> => {
const nullInterpretation: FilterInterpretation = {
maxResults: maxFuzzyFilterResults,
penalty: 0,
words,
consumed: 0,
varType: VarType.Text,
}
const filteredInterpretations = trimf(filter([nullInterpretation]))
return filteredInterpretations.map((interpretation: FilterInterpretation): DirectiveInterpretation<P> => {
return {
filterInterpretation: interpretation,
runParams: updateParams(runParams, name, paramValue(interpretation)),
remainingWords: words.slice(interpretation.consumed),
}
})
}
}
// Don't require the Var to be set or to exist in txt
// Options must either match exactly and consume words,
// or match partially or not at all and consume nothing,
// yeilding the defaultVal value for the 'name'ed parameter
function optionDirective<P extends ParamMap>
(name: string | undefined, defaultVal: ParamValue, filter: Filter): Directive<P> {
const varType = !isNullOrUndefined(defaultVal) ?
(isNumber(defaultVal) ? VarType.Numeric : VarType.Text) :
VarType.Undefined
// Return all possible var match interpretations plus the default interpretation
return (words: string[], runParams: P, maxFuzzyFilterResults: number): DirectiveInterpretations<P> => [
...varDirective<P>(name, filter)(words, runParams, maxFuzzyFilterResults),
// Default interpretation
{
filterInterpretation: {
maxResults: maxFuzzyFilterResults,
penalty: 0,
varType,
words: [],
consumed: 0,
},
runParams: updateParams(runParams, name, defaultVal),
remainingWords: words,
},
]
}
// ------------------------ Directives ---------------------------------------------
// Require a filtered subset of the input sentence and store it as 'name'
// in the named parameter list that'll be used to invoke the command's run function
export const Var = (name: string, filter: Filter) => varDirective(name, filter)
// Same as Var, but a match is not required for the command to run
// If the filter returns no match, don't consume any input words and move on
export const Option = (name: string, defaultVal: string | number | undefined, filter: Filter) =>
optionDirective(name, defaultVal, filter)
// Similar to Var, but does not add the filtered value to the named parameter list
export const Require = (filter: Filter) => varDirective(undefined, filter)
// Similar to Option, but does not add the filtered value to the named parameter list
export const Ignore = (filter: Filter) => optionDirective(undefined, undefined, filter)
// ------------------------- Phrase or Word Filters ------------------------------------
// Match a phrase of specific word length
export const Phrase = (wordCount: number, filter: Filter = passThruFilter) => phraseFilter(wordCount, filter)
// Match next 1 word from remaining words
export const Word = (filter: Filter = passThruFilter) => Phrase(1, filter)
// Match a phrase by stopword. The match will exclude the stopword by default.
// Often good to use an Exact filter after this to avoid fuzzily overmatching
export const StopPhrase = (stopwords: string[], includeStopword: boolean = false, filter: Filter = passThruFilter) =>
stopPhraseFilter(stopwords, includeStopword, filter)
// Match all remaining words to the end of input
// You should REALLY consider NOT using this since it may overmatch,
// Use only with ignoreFuzzy or using Exact filters before this filter
export const Sentence = (filter: Filter = passThruFilter) => remainingPhraseFilter(filter)
// ---------------------------- Boolean Filters -----------------------------------------
// Give back interpretations that match any of the filters.
// Assume the minimum penalty across each words / VarType duplicate interpretation
export const Or = (filters: Filter[]) => orFilter(filters)
// Give back only the interpretations that match words across all filters, and assume the worst penalty
// over all the filtered outputs across each interpretation
export const And = (filters: Filter[]) => andFilter(filters)
// ----------------------------- String & Numeric filters --------------------------------
// Match any phrases or words and pass them along
export const Any = (whitelist: string[], filter: Filter = passThruFilter) => anyFilter(whitelist, filter)
// Same as Any but dynamically generate the whitelist
export const GetAny = (whitelistGenerator: () => string[], filter: Filter = passThruFilter) => lazyAnyFilter(whitelistGenerator, filter)
// Only match phrases or words that are NOT in the blacklist. All blacklist entries must have the same word count!
// For multi-length word lists, use multiple Nones.
// The blacklist[0]'s word length will be consumed from the input senetence if filter matches
export const None = (blacklist: string[], filter: Filter = passThruFilter) => noneFilter(blacklist, filter)
// Same as None but dynamically generate the blacklist
export const GetNone = (blacklistGenerator: () => string[], filter: Filter = passThruFilter) => lazyNoneFilter(blacklistGenerator, filter)
// Exact results only
export const Exact = (preFilter: Filter) => precisionFilter(0, preFilter)
// Allow only results at or above a certain probability
export const Threshold = (maxAllowablePenalty: number, preFilter: Filter) =>
precisionFilter(maxAllowablePenalty, preFilter)
// Match any number including decimals like 3.14
// It will appear as a Number type in your command's runFunc
export const Numeric = (min: number = Number.MIN_VALUE, max: number = Number.MAX_VALUE, filter: Filter = passThruFilter) =>
numericFilter(min, max, filter)
// --- TO DO: Transformers, handle in your runFunc manually for now!
//export const Map = (phraseTranslator: PhraseMap, filter: Filter = passThruFilter) => mapper(phraseTranslator, filter)