1 | import _ from 'lodash';
|
2 | import { reduceDecisionRules } from './reducer';
|
3 | import { tzFromOffset } from './time';
|
4 | import { CraftAiDecisionError, CraftAiNullDecisionError, CraftAiUnknownError } from './errors';
|
5 | import { formatDecisionRules, formatProperty } from './formatter';
|
6 | import isTimezone, { getTimezoneKey } from './timezones';
|
7 |
|
8 | const DECISION_FORMAT_VERSION = '1.1.0';
|
9 |
|
10 | const OPERATORS = {
|
11 | 'is': (context, value) => context === value,
|
12 | '>=': (context, value) => context * 1 >= value,
|
13 | '<': (context, value) => context * 1 < value,
|
14 | '[in[': (context, value) => {
|
15 | let context_val = context * 1;
|
16 | let from = value[0];
|
17 | let to = value[1];
|
18 |
|
19 | if (from < to) {
|
20 | return (context_val >= from && context_val < to);
|
21 | }
|
22 |
|
23 | else {
|
24 | return (context_val >= from || context_val < to);
|
25 | }
|
26 | }
|
27 | };
|
28 |
|
29 | const VALUE_VALIDATOR = {
|
30 | continuous: (value) => _.isFinite(value),
|
31 | enum: (value) => _.isString(value),
|
32 | timezone: (value) => isTimezone(value),
|
33 | time_of_day: (value) => _.isFinite(value) && value >= 0 && value < 24,
|
34 | day_of_week: (value) => _.isInteger(value) && value >= 0 && value <= 6,
|
35 | day_of_month: (value) => _.isInteger(value) && value >= 1 && value <= 31,
|
36 | month_of_year: (value) => _.isInteger(value) && value >= 1 && value <= 12
|
37 | };
|
38 |
|
39 | function decideRecursion(node, context) {
|
40 |
|
41 | if (!(node.children && node.children.length)) {
|
42 | if (node.predicted_value == null) {
|
43 | return {
|
44 | predicted_value: undefined,
|
45 | confidence: undefined,
|
46 | decision_rules: [],
|
47 | error: {
|
48 | name: 'CraftAiNullDecisionError',
|
49 | message: 'Unable to take decision: the decision tree has no valid predicted value for the given context.'
|
50 | }
|
51 | };
|
52 | }
|
53 |
|
54 | let leafNode = {
|
55 | predicted_value: node.predicted_value,
|
56 | confidence: node.confidence || 0,
|
57 | decision_rules: []
|
58 | };
|
59 |
|
60 | if (!_.isUndefined(node.standard_deviation)) {
|
61 | leafNode.standard_deviation = node.standard_deviation;
|
62 | }
|
63 |
|
64 | return leafNode;
|
65 | }
|
66 |
|
67 |
|
68 | const matchingChild = _.find(
|
69 | node.children,
|
70 | (child) => {
|
71 | const decision_rule = child.decision_rule;
|
72 | const property = decision_rule.property;
|
73 | if (_.isUndefined(context[property])) {
|
74 |
|
75 | return {
|
76 | predicted_value: undefined,
|
77 | confidence: undefined,
|
78 | error: {
|
79 | name: 'CraftAiUnknownError',
|
80 | message: `Unable to take decision: property '${property}' is missing from the given context.`
|
81 | }
|
82 | };
|
83 | }
|
84 |
|
85 | return OPERATORS[decision_rule.operator](context[property], decision_rule.operand);
|
86 | }
|
87 | );
|
88 |
|
89 |
|
90 | if (matchingChild && matchingChild.error) {
|
91 | return matchingChild;
|
92 | }
|
93 |
|
94 | if (_.isUndefined(matchingChild)) {
|
95 |
|
96 | const operandList = _.uniq(_.map(_.values(node.children), (child) => child.decision_rule.operand));
|
97 | const property = _.head(node.children).decision_rule.property;
|
98 | return {
|
99 | predicted_value: undefined,
|
100 | confidence: undefined,
|
101 | decision_rules: [],
|
102 | error: {
|
103 | name: 'CraftAiNullDecisionError',
|
104 | message: `Unable to take decision: value '${context[property]}' for property '${property}' doesn't validate any of the decision rules.`,
|
105 | metadata: {
|
106 | property: property,
|
107 | value: context[property],
|
108 | expected_values: operandList
|
109 | }
|
110 | }
|
111 | };
|
112 | }
|
113 |
|
114 |
|
115 | const result = decideRecursion(matchingChild, context);
|
116 |
|
117 | let finalResult = _.extend(result, {
|
118 | decision_rules: [matchingChild.decision_rule].concat(result.decision_rules)
|
119 | });
|
120 |
|
121 | return finalResult;
|
122 | }
|
123 |
|
124 | function checkContext(configuration) {
|
125 |
|
126 | const expectedProperties = _.difference(
|
127 | _.keys(configuration.context),
|
128 | configuration.output
|
129 | );
|
130 |
|
131 |
|
132 | const validators = _.map(expectedProperties, (property) => {
|
133 | const otherValidator = () => {
|
134 | console.warn(`WARNING: "${configuration.context[property].type}" is not a supported type. Please refer to the documention to see what type you can use`);
|
135 | return true;
|
136 | };
|
137 | return {
|
138 | property,
|
139 | type: configuration.context[property].type,
|
140 | validator: VALUE_VALIDATOR[configuration.context[property].type] || otherValidator
|
141 | };
|
142 | });
|
143 |
|
144 | return (context) => {
|
145 | const { badProperties, missingProperties } = _.reduce(
|
146 | validators,
|
147 | ({ badProperties, missingProperties }, { property, type, validator }) => {
|
148 | const value = context[property];
|
149 | if (value === undefined) {
|
150 | missingProperties.push(property);
|
151 | }
|
152 | else if (!validator(value)) {
|
153 | badProperties.push({ property, type, value });
|
154 | }
|
155 | return { badProperties, missingProperties };
|
156 | },
|
157 | { badProperties: [], missingProperties: [] }
|
158 | );
|
159 |
|
160 | if (missingProperties.length || badProperties.length) {
|
161 | const messages = _.concat(
|
162 | _.map(missingProperties, (property) => `expected property '${property}' is not defined`),
|
163 | _.map(badProperties, ({ property, type, value }) => `'${value}' is not a valid value for property '${property}' of type '${type}'`)
|
164 | );
|
165 | throw new CraftAiDecisionError({
|
166 | message: `Unable to take decision, the given context is not valid: ${messages.join(', ')}.`,
|
167 | metadata: _.assign({}, missingProperties.length && { missingProperties }, badProperties.length && { badProperties })
|
168 | });
|
169 | }
|
170 | };
|
171 | }
|
172 |
|
173 | function decide(configuration, trees, context) {
|
174 | checkContext(configuration)(context);
|
175 |
|
176 |
|
177 | const timezoneProperty = getTimezoneKey(configuration.context);
|
178 | if (!_.isUndefined(timezoneProperty)) {
|
179 | context[timezoneProperty] = tzFromOffset(context[timezoneProperty]);
|
180 | }
|
181 | return {
|
182 | _version: DECISION_FORMAT_VERSION,
|
183 | context,
|
184 | output: _.assign(..._.map(configuration.output, (output) => {
|
185 | let decision = decideRecursion(trees[output], context);
|
186 | if (decision.error) {
|
187 | switch (decision.error.name) {
|
188 | case 'CraftAiNullDecisionError':
|
189 | throw new CraftAiNullDecisionError({
|
190 | message: decision.error.message,
|
191 | metadata: _.extend(decision.error.metadata, {
|
192 | decision_rules: decision.decision_rules
|
193 | })
|
194 | });
|
195 | default:
|
196 | throw new CraftAiUnknownError({
|
197 | message: decision.error.message
|
198 | });
|
199 | }
|
200 | }
|
201 | return {
|
202 | [output]: decision
|
203 | };
|
204 | }))
|
205 | };
|
206 | }
|
207 |
|
208 | export { formatDecisionRules, formatProperty, reduceDecisionRules, decide }; |
\ | No newline at end of file |