prisme-flow
Version:
prisme platform flow engine
441 lines (391 loc) • 11.2 kB
JavaScript
/**
* Created by prisme.io on 09/06/2017.
*/
var nlp = require('nlp_compromise')
var _ = require('underscore')
var moment = require('moment')
var regexps = require('./helpers/regexps')
var levenshtein = require('fast-levenshtein')
var matchLevenshtain = function (term, word) {
// get the Levenshtein distance based on the length of the word, for length = 2 distance must be 0 or "off" and "on"
// will be confused
var distance = null
if (term.length <= 2) {
distance = 0
} else if (term.length <= 4) {
distance = 1
} else {
distance = 2
}
return levenshtein.get(term, word) <= distance
}
var MatchRule = function (obj) {
if (_.isString(obj)) {
var parsed = obj.match(/([a-zA-Z0-9%$£# ]*){0,1}(\[[a-zA-Z0-9]*\]){0,1}(->[a-zA-Z0-9]*){0,1}/)
if (parsed != null) {
_.extend(this, {
text: parsed[1] != null ? parsed[1] : null,
type: parsed[2] != null ? parsed[2].replace('[', '').replace(']', '') : 'word',
variable: parsed[3] != null ? parsed[3].replace('->', '') : null,
value: null,
distance: 0
})
}
} else if (_.isObject(obj)) {
_.extend(this, {
text: null,
type: null,
variable: null,
value: null,
distance: 0,
raw: null
}, obj)
}
return this
}
/*
todo
- add city: String, // Toronto, Canada -> Toronto, region: String, // Toronto, Ontario -> Ontario, country: String,
- add float
- add monday
*/
_.extend(MatchRule.prototype, {
_matchingRules: [
function (term) {
// match if type if not specified, just use lev
if (_.isEmpty(this.type) && !_.isEmpty(this.text)) {
if (matchLevenshtain(this.text, term.text)) {
this.raw = term.text
return true
}
}
return false
},
function (term) {
var matchType = (this.type === 'noun' && term.pos.Noun) ||
(this.type === 'adjective' && term.pos.Adjective) ||
(this.type === 'conjunction' && term.pos.Conjunction) ||
(this.type === 'adverb' && term.pos.Adverb) ||
(this.type === 'preposition' && term.pos.Preposition) ||
(this.type === 'determiner' && term.pos.Determiner) ||
// || (this.type === 'symbol' && term.pos.Symbol)
(this.type === 'word')
// if the type is ok, capture the text or verify
if (matchType) {
if (!_.isEmpty(this.text)) {
if (matchLevenshtain(this.text, term.text)) {
this.raw = term.text
return true
}
} else {
// catches all
this.value = term.text
this.raw = term.text
return true
}
}
return false
},
function (term) {
// match exactly a symbol
if (this.type === 'symbol' && term.pos.Symbol) {
if (!_.isEmpty(this.text)) {
if (this.text === term.text) {
this.raw = term.text
return true
}
} else {
// catches all
this.value = term.text
this.raw = term.text
return true
}
}
return false
},
function (term) {
// this rule match a numeric value, improve it with keywords like float or integer
if (term.pos.Value) {
if (this.type == 'number') {
this.value = term.number
this.raw = term.text
return true
}
}
return false
},
function (term) {
// detect currency term, sometimes nlp chuncks two nouns, takes only the first one
if (this.type === 'currency' && term.pos.Currency) {
this.value = term.text.split(' ')[0]
this.raw = term.text
return true
}
return false
},
function (term) {
// match a well formatted email
if (this.type === 'email' && regexps.email(term.text) != null) {
this.value = term.text
this.raw = term.text
return true
}
return false
},
function (term) {
// match a well formatted email
// if (this.type === 'url' && regexps.url(term.text) != null) {
if (this.type === 'url') {
this.value = term.text
this.raw = term.text
return true
}
return false
},
function (term) {
// match a verb
if (this.type === 'verb' && term.pos.Verb) {
if (!_.isEmpty(this.text)) {
if (this.text === term.text || this.text == term.root()) {
this.raw = term.text
return true
}
} else {
// catches all
this.value = term.text
this.raw = term.text
return true
}
}
return false
},
function (term) {
// match a person
if (this.type === 'person' && term.pos.Person) {
if (!_.isEmpty(this.text)) {
return this.text === term.text
}
// catches all
this.value = term.text
return true
}
return false
},
function (term) {
// match a date
if (this.type === 'date' && term.pos.Date) {
var date = moment()
if (term.data.year != null) {
date.year(term.data.year)
}
if (term.data.month != null) {
date.month(term.data.month)
}
if (term.data.day != null) {
date.date(term.data.day)
}
this.raw = term.text
this.value = date
return true
}
return false
}
],
debug: function () {
// eslint-disable-next-line no-console
console.log(this.toJSON())
},
clone: function () {
return new MatchRule(this.toJSON())
},
toJSON: function () {
return {
text: this.text,
type: this.type,
variable: this.variable,
value: this.value,
distance: this.distance,
raw: this.raw
}
},
/**
* @method match
* Check if the term matches a rule
* @param {Term} term
* @return {Boolean}
*/
match: function (term) {
var _this = this
return _(this._matchingRules).any(function (func) {
return func.call(_this, term)
})
}
})
var MatchRules = function (objs) {
var _this = this
_this._models = []
if (_.isArray(objs)) {
objs.forEach(function (obj) {
_this._models.push(new MatchRule(obj))
})
}
}
_.extend(MatchRules.prototype, {
prepend: function (rule) {
this._models.unshift(rule)
return this
},
count: function () {
return this._models.length
},
clone: function () {
return new MatchRules(this.map(function (rule) {
return rule.toJSON()
}))
},
map: function (func) {
return _(this._models).map(func)
},
forEach: function (func) {
_(this._models).each(func)
return this
},
/**
* @method head
* Get the first rule of the set
* @return {MatchRule}
*/
head: function () {
return this.count() >= 1 ? this._models[0] : null
},
at: function (idx) {
return idx < this._models.length ? this._models[idx] : null
},
/**
* @method empty
* Tells if the rules collection is empty
* @return {Boolean}
*/
empty: function () {
return this.count() === 0
},
toJSON: function () {
return this.map(function (rule) {
return rule.toJSON()
})
},
/**
* @method tail
* Return a cloned element of the rules, except the first one
* @return {MatchRules}
*/
tail: function () {
return new MatchRules(_(this.toJSON()).tail())
}
})
/**
* @class Terms
* A collection of parsed terms from a sentence}
*/
var Terms = function (terms) {
this._terms = terms
return this
}
_.extend(Terms.prototype, {
count: function () {
return this._terms.length
},
head: function () {
return this.count() >= 1 ? this._terms[0] : null
},
at: function (idx) {
return idx < this._terms.length ? this._terms[idx] : null
},
/**
* @method empty
* Tells if the terms collection is empty
* @return {Boolean}
*/
empty: function () {
return this.count() === 0
},
/**
* @method tail
* Return a cloned version of the terms, excluded the first one
* @return {Terms}
*/
tail: function () {
return new Terms(_(this._terms).tail())
}
})
var matchRules = function (sentence, rules, distance) {
distance = distance || 0
// var matched = [];
// if there something
if (!sentence.empty() && !rules.empty()) {
// always clone the rule before matching (the match stores data into the rule), no side effects here
var clonedRules = rules.clone()
// che if top rule match with top term
if (clonedRules.head().match(sentence.head())) {
// set the distance that matched
clonedRules.head().distance = distance
// if just one rules is left means we're done here and the whole sentence is matched
// return the cloned rules then
if (clonedRules.count() == 1) {
return [clonedRules]
}
// not done yet, we have to check more rules, so tail both the sentence terms and the rules and
// check again, resets the distance
var matchedWithBothTailed = matchRules(sentence.tail(), clonedRules.tail())
// for each of the matched rules, I've to prepend the previous head checked in this round
_(matchedWithBothTailed).each(function (rule) {
rule.prepend(clonedRules.head())
})
// can be a match also with the rules without tailing it, for example if we're searching for a
// [noun] [color] it could match "[car] is a bmw [blue]" but also "car is a [bmw] [blue]",
// so we're tailing the terms but not the rules
var matchedWithSentenceTailed = matchRules(sentence.tail(), clonedRules)
// join all together
return _.compact(_.union(matchedWithBothTailed, matchedWithSentenceTailed))
}
// if the first term doesn't match, remove it and try to match the rest of the sentence, since we have discarded
// a token, increase distance
return matchRules(sentence.tail(), rules, distance + 1)
// enqueue the found rules with the stack
// matched.push.apply(matched, matchedWithSentenceTail);
}
return []
}
module.exports = {
Terms: Terms,
MatchRules: MatchRules,
MatchRule: MatchRule,
matchRules: matchRules,
matchRule: function (terms, rules) {
var matches = matchRules(terms, rules)
// todo improve detect here with distance
return !_.isEmpty(matches) ? matches[0] : null
},
matchLevenshtain: matchLevenshtain,
/**
* @method parseSentence
* Parse a string with nlp-compromise with some corrections (like the currency with $ symbols)
* @param {String} str
* @return {Array}
*/
parseSentence: function (str) {
// detach symbols from leading labels, for example convert "40$" into "40 $", otherwise NLP is not able to parse
// them correctly
str = str.replace(/([0-9a-zA-Z]*)([%|$|€|£|#])/g, '$1 $2')
var phrase = nlp.text(str)
// correct currency symbols with the currency flag
phrase.terms().forEach(function (term) {
if (term.text == '$' || term.text == '€') {
term.tag = 'Currency'
term.pos.Currency = true
term.pos.Noun = true
}
})
return new Terms(phrase.terms())
}
}