decision_agent 0.1.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.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +1060 -0
- data/bin/decision_agent +104 -0
- data/lib/decision_agent/agent.rb +147 -0
- data/lib/decision_agent/audit/adapter.rb +9 -0
- data/lib/decision_agent/audit/logger_adapter.rb +27 -0
- data/lib/decision_agent/audit/null_adapter.rb +8 -0
- data/lib/decision_agent/context.rb +42 -0
- data/lib/decision_agent/decision.rb +51 -0
- data/lib/decision_agent/dsl/condition_evaluator.rb +133 -0
- data/lib/decision_agent/dsl/rule_parser.rb +36 -0
- data/lib/decision_agent/dsl/schema_validator.rb +275 -0
- data/lib/decision_agent/errors.rb +62 -0
- data/lib/decision_agent/evaluation.rb +52 -0
- data/lib/decision_agent/evaluators/base.rb +15 -0
- data/lib/decision_agent/evaluators/json_rule_evaluator.rb +51 -0
- data/lib/decision_agent/evaluators/static_evaluator.rb +31 -0
- data/lib/decision_agent/replay/replay.rb +147 -0
- data/lib/decision_agent/scoring/base.rb +19 -0
- data/lib/decision_agent/scoring/consensus.rb +40 -0
- data/lib/decision_agent/scoring/max_weight.rb +16 -0
- data/lib/decision_agent/scoring/threshold.rb +40 -0
- data/lib/decision_agent/scoring/weighted_average.rb +26 -0
- data/lib/decision_agent/version.rb +3 -0
- data/lib/decision_agent/web/public/app.js +580 -0
- data/lib/decision_agent/web/public/index.html +190 -0
- data/lib/decision_agent/web/public/styles.css +558 -0
- data/lib/decision_agent/web/server.rb +255 -0
- data/lib/decision_agent.rb +29 -0
- data/spec/agent_spec.rb +249 -0
- data/spec/api_contract_spec.rb +430 -0
- data/spec/audit_adapters_spec.rb +74 -0
- data/spec/comprehensive_edge_cases_spec.rb +1777 -0
- data/spec/context_spec.rb +84 -0
- data/spec/dsl_validation_spec.rb +648 -0
- data/spec/edge_cases_spec.rb +353 -0
- data/spec/examples/feedback_aware_evaluator_spec.rb +460 -0
- data/spec/json_rule_evaluator_spec.rb +587 -0
- data/spec/replay_edge_cases_spec.rb +699 -0
- data/spec/replay_spec.rb +210 -0
- data/spec/scoring_spec.rb +225 -0
- data/spec/spec_helper.rb +28 -0
- metadata +133 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Scoring
|
|
3
|
+
class MaxWeight < Base
|
|
4
|
+
def score(evaluations)
|
|
5
|
+
return { decision: nil, confidence: 0.0 } if evaluations.empty?
|
|
6
|
+
|
|
7
|
+
max_eval = evaluations.max_by(&:weight)
|
|
8
|
+
|
|
9
|
+
{
|
|
10
|
+
decision: max_eval.decision,
|
|
11
|
+
confidence: round_confidence(normalize_confidence(max_eval.weight))
|
|
12
|
+
}
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Scoring
|
|
3
|
+
class Threshold < Base
|
|
4
|
+
attr_reader :threshold, :fallback_decision
|
|
5
|
+
|
|
6
|
+
def initialize(threshold: 0.7, fallback_decision: "no_decision")
|
|
7
|
+
@threshold = threshold.to_f
|
|
8
|
+
@fallback_decision = fallback_decision
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def score(evaluations)
|
|
12
|
+
return { decision: @fallback_decision, confidence: 0.0 } if evaluations.empty?
|
|
13
|
+
|
|
14
|
+
grouped = evaluations.group_by(&:decision)
|
|
15
|
+
|
|
16
|
+
weighted_scores = grouped.map do |decision, evals|
|
|
17
|
+
total_weight = evals.sum(&:weight)
|
|
18
|
+
avg_weight = total_weight / evals.size
|
|
19
|
+
[decision, avg_weight]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
weighted_scores.sort_by! { |_, weight| -weight }
|
|
23
|
+
|
|
24
|
+
winning_decision, winning_weight = weighted_scores.first
|
|
25
|
+
|
|
26
|
+
if winning_weight >= @threshold
|
|
27
|
+
{
|
|
28
|
+
decision: winning_decision,
|
|
29
|
+
confidence: round_confidence(normalize_confidence(winning_weight))
|
|
30
|
+
}
|
|
31
|
+
else
|
|
32
|
+
{
|
|
33
|
+
decision: @fallback_decision,
|
|
34
|
+
confidence: round_confidence(normalize_confidence(winning_weight * 0.5))
|
|
35
|
+
}
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
module DecisionAgent
|
|
2
|
+
module Scoring
|
|
3
|
+
class WeightedAverage < Base
|
|
4
|
+
def score(evaluations)
|
|
5
|
+
return { decision: nil, confidence: 0.0 } if evaluations.empty?
|
|
6
|
+
|
|
7
|
+
grouped = evaluations.group_by(&:decision)
|
|
8
|
+
|
|
9
|
+
weighted_scores = grouped.map do |decision, evals|
|
|
10
|
+
total_weight = evals.sum(&:weight)
|
|
11
|
+
[decision, total_weight]
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
winning_decision, winning_weight = weighted_scores.max_by { |_, weight| weight }
|
|
15
|
+
|
|
16
|
+
total_weight = evaluations.sum(&:weight)
|
|
17
|
+
confidence = total_weight > 0 ? winning_weight / total_weight : 0.0
|
|
18
|
+
|
|
19
|
+
{
|
|
20
|
+
decision: winning_decision,
|
|
21
|
+
confidence: round_confidence(normalize_confidence(confidence))
|
|
22
|
+
}
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
// DecisionAgent Rule Builder - Main Application
|
|
2
|
+
class RuleBuilder {
|
|
3
|
+
constructor() {
|
|
4
|
+
this.rules = [];
|
|
5
|
+
this.currentRule = null;
|
|
6
|
+
this.currentRuleIndex = null;
|
|
7
|
+
this.currentCondition = null;
|
|
8
|
+
this.init();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
init() {
|
|
12
|
+
this.bindEvents();
|
|
13
|
+
this.updateJSONPreview();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
bindEvents() {
|
|
17
|
+
// Rule management
|
|
18
|
+
document.getElementById('addRuleBtn').addEventListener('click', () => this.openRuleModal());
|
|
19
|
+
document.getElementById('saveRuleBtn').addEventListener('click', () => this.saveRule());
|
|
20
|
+
document.getElementById('closeModalBtn').addEventListener('click', () => this.closeModal());
|
|
21
|
+
document.getElementById('cancelModalBtn').addEventListener('click', () => this.closeModal());
|
|
22
|
+
|
|
23
|
+
// Actions
|
|
24
|
+
document.getElementById('validateBtn').addEventListener('click', () => this.validateRules());
|
|
25
|
+
document.getElementById('clearBtn').addEventListener('click', () => this.clearAll());
|
|
26
|
+
document.getElementById('loadExampleBtn').addEventListener('click', () => this.loadExample());
|
|
27
|
+
|
|
28
|
+
// Export/Import
|
|
29
|
+
document.getElementById('copyBtn').addEventListener('click', () => this.copyJSON());
|
|
30
|
+
document.getElementById('downloadBtn').addEventListener('click', () => this.downloadJSON());
|
|
31
|
+
document.getElementById('importFile').addEventListener('change', (e) => this.importJSON(e));
|
|
32
|
+
|
|
33
|
+
// Modal close on outside click
|
|
34
|
+
document.getElementById('ruleModal').addEventListener('click', (e) => {
|
|
35
|
+
if (e.target.id === 'ruleModal') {
|
|
36
|
+
this.closeModal();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// Operator change - hide/show value input
|
|
41
|
+
document.addEventListener('change', (e) => {
|
|
42
|
+
if (e.target.classList.contains('operator-select')) {
|
|
43
|
+
this.handleOperatorChange(e.target);
|
|
44
|
+
}
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
openRuleModal(index = null) {
|
|
49
|
+
this.currentRuleIndex = index;
|
|
50
|
+
const modal = document.getElementById('ruleModal');
|
|
51
|
+
const modalTitle = document.getElementById('modalTitle');
|
|
52
|
+
|
|
53
|
+
if (index !== null) {
|
|
54
|
+
// Edit existing rule
|
|
55
|
+
this.currentRule = { ...this.rules[index] };
|
|
56
|
+
modalTitle.textContent = `Edit Rule: ${this.currentRule.id}`;
|
|
57
|
+
this.populateRuleModal(this.currentRule);
|
|
58
|
+
} else {
|
|
59
|
+
// New rule
|
|
60
|
+
this.currentRule = {
|
|
61
|
+
id: '',
|
|
62
|
+
if: { field: '', op: 'eq', value: '' },
|
|
63
|
+
then: { decision: '', weight: 0.8, reason: '' }
|
|
64
|
+
};
|
|
65
|
+
modalTitle.textContent = 'Create New Rule';
|
|
66
|
+
this.populateRuleModal(this.currentRule);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
modal.classList.remove('hidden');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
populateRuleModal(rule) {
|
|
73
|
+
document.getElementById('ruleId').value = rule.id || '';
|
|
74
|
+
document.getElementById('thenDecision').value = rule.then?.decision || '';
|
|
75
|
+
document.getElementById('thenWeight').value = rule.then?.weight || 0.8;
|
|
76
|
+
document.getElementById('thenReason').value = rule.then?.reason || '';
|
|
77
|
+
|
|
78
|
+
// Build condition UI
|
|
79
|
+
const conditionBuilder = document.getElementById('conditionBuilder');
|
|
80
|
+
conditionBuilder.innerHTML = '';
|
|
81
|
+
|
|
82
|
+
if (!rule.if) {
|
|
83
|
+
this.addFieldCondition(conditionBuilder);
|
|
84
|
+
} else {
|
|
85
|
+
this.buildConditionUI(rule.if, conditionBuilder);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
buildConditionUI(condition, container) {
|
|
90
|
+
if (condition.field !== undefined) {
|
|
91
|
+
// Field condition
|
|
92
|
+
const conditionEl = this.createFieldCondition(condition);
|
|
93
|
+
container.appendChild(conditionEl);
|
|
94
|
+
} else if (condition.all !== undefined) {
|
|
95
|
+
// All (AND) condition
|
|
96
|
+
const compositeEl = this.createCompositeCondition('all', condition.all);
|
|
97
|
+
container.appendChild(compositeEl);
|
|
98
|
+
} else if (condition.any !== undefined) {
|
|
99
|
+
// Any (OR) condition
|
|
100
|
+
const compositeEl = this.createCompositeCondition('any', condition.any);
|
|
101
|
+
container.appendChild(compositeEl);
|
|
102
|
+
} else {
|
|
103
|
+
// Fallback
|
|
104
|
+
this.addFieldCondition(container);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
createFieldCondition(data = {}) {
|
|
109
|
+
const template = document.getElementById('fieldConditionTemplate');
|
|
110
|
+
const clone = template.content.cloneNode(true);
|
|
111
|
+
const conditionItem = clone.querySelector('.condition-item');
|
|
112
|
+
|
|
113
|
+
// Populate data
|
|
114
|
+
if (data.field) conditionItem.querySelector('.field-path').value = data.field;
|
|
115
|
+
if (data.op) conditionItem.querySelector('.operator-select').value = data.op;
|
|
116
|
+
if (data.value !== undefined) conditionItem.querySelector('.field-value').value = data.value;
|
|
117
|
+
|
|
118
|
+
// Handle operator-specific visibility
|
|
119
|
+
const operatorSelect = conditionItem.querySelector('.operator-select');
|
|
120
|
+
this.handleOperatorChange(operatorSelect);
|
|
121
|
+
|
|
122
|
+
// Remove button
|
|
123
|
+
conditionItem.querySelector('.btn-remove').addEventListener('click', (e) => {
|
|
124
|
+
conditionItem.remove();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// Type change
|
|
128
|
+
conditionItem.querySelector('.condition-type-select').addEventListener('change', (e) => {
|
|
129
|
+
this.convertConditionType(conditionItem, e.target.value);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return conditionItem;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
createCompositeCondition(type = 'all', subconditions = []) {
|
|
136
|
+
const template = document.getElementById('compositeConditionTemplate');
|
|
137
|
+
const clone = template.content.cloneNode(true);
|
|
138
|
+
const conditionItem = clone.querySelector('.condition-item');
|
|
139
|
+
const typeSelect = conditionItem.querySelector('.condition-type-select');
|
|
140
|
+
const subContainer = conditionItem.querySelector('.subconditions-container');
|
|
141
|
+
|
|
142
|
+
// Set type
|
|
143
|
+
typeSelect.value = type;
|
|
144
|
+
|
|
145
|
+
// Add subconditions
|
|
146
|
+
if (subconditions.length === 0) {
|
|
147
|
+
// Add one empty field condition
|
|
148
|
+
subContainer.appendChild(this.createFieldCondition());
|
|
149
|
+
} else {
|
|
150
|
+
subconditions.forEach(subcond => {
|
|
151
|
+
this.buildConditionUI(subcond, subContainer);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Add subcondition button
|
|
156
|
+
conditionItem.querySelector('.btn-add-subcondition').addEventListener('click', () => {
|
|
157
|
+
subContainer.appendChild(this.createFieldCondition());
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Remove button
|
|
161
|
+
conditionItem.querySelector('.btn-remove').addEventListener('click', () => {
|
|
162
|
+
conditionItem.remove();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
// Type change
|
|
166
|
+
typeSelect.addEventListener('change', (e) => {
|
|
167
|
+
this.convertConditionType(conditionItem, e.target.value);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
return conditionItem;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
convertConditionType(conditionItem, newType) {
|
|
174
|
+
const parent = conditionItem.parentElement;
|
|
175
|
+
if (!parent) return;
|
|
176
|
+
|
|
177
|
+
if (newType === 'field') {
|
|
178
|
+
// Convert to field condition
|
|
179
|
+
const newCondition = this.createFieldCondition();
|
|
180
|
+
parent.replaceChild(newCondition, conditionItem);
|
|
181
|
+
} else {
|
|
182
|
+
// Convert to composite (all/any)
|
|
183
|
+
const newCondition = this.createCompositeCondition(newType);
|
|
184
|
+
parent.replaceChild(newCondition, conditionItem);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
addFieldCondition(container) {
|
|
189
|
+
const conditionEl = this.createFieldCondition();
|
|
190
|
+
container.appendChild(conditionEl);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
handleOperatorChange(selectElement) {
|
|
194
|
+
const valueInput = selectElement.parentElement.querySelector('.field-value');
|
|
195
|
+
const operator = selectElement.value;
|
|
196
|
+
|
|
197
|
+
if (operator === 'present' || operator === 'blank') {
|
|
198
|
+
valueInput.style.display = 'none';
|
|
199
|
+
valueInput.value = '';
|
|
200
|
+
} else {
|
|
201
|
+
valueInput.style.display = 'block';
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
parseConditionUI(conditionElement) {
|
|
206
|
+
const typeSelect = conditionElement.querySelector('.condition-type-select');
|
|
207
|
+
const type = typeSelect.value;
|
|
208
|
+
|
|
209
|
+
if (type === 'field') {
|
|
210
|
+
// Field condition
|
|
211
|
+
const field = conditionElement.querySelector('.field-path').value.trim();
|
|
212
|
+
const op = conditionElement.querySelector('.operator-select').value;
|
|
213
|
+
const value = conditionElement.querySelector('.field-value').value;
|
|
214
|
+
|
|
215
|
+
const condition = { field, op };
|
|
216
|
+
|
|
217
|
+
// Add value only if needed
|
|
218
|
+
if (op !== 'present' && op !== 'blank') {
|
|
219
|
+
// Try to parse as JSON for arrays/objects
|
|
220
|
+
try {
|
|
221
|
+
condition.value = JSON.parse(value);
|
|
222
|
+
} catch {
|
|
223
|
+
condition.value = value;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return condition;
|
|
228
|
+
} else {
|
|
229
|
+
// Composite condition (all/any)
|
|
230
|
+
const subContainer = conditionElement.querySelector('.subconditions-container');
|
|
231
|
+
const subconditions = Array.from(subContainer.children).map(child =>
|
|
232
|
+
this.parseConditionUI(child)
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
return { [type]: subconditions };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
saveRule() {
|
|
240
|
+
// Validate inputs
|
|
241
|
+
const ruleId = document.getElementById('ruleId').value.trim();
|
|
242
|
+
const thenDecision = document.getElementById('thenDecision').value.trim();
|
|
243
|
+
|
|
244
|
+
if (!ruleId) {
|
|
245
|
+
alert('Rule ID is required');
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
if (!thenDecision) {
|
|
250
|
+
alert('Decision is required');
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Parse condition
|
|
255
|
+
const conditionBuilder = document.getElementById('conditionBuilder');
|
|
256
|
+
const conditionElements = Array.from(conditionBuilder.children);
|
|
257
|
+
|
|
258
|
+
if (conditionElements.length === 0) {
|
|
259
|
+
alert('At least one condition is required');
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const ifCondition = this.parseConditionUI(conditionElements[0]);
|
|
264
|
+
|
|
265
|
+
// Build then clause
|
|
266
|
+
const thenClause = {
|
|
267
|
+
decision: thenDecision
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
const weight = parseFloat(document.getElementById('thenWeight').value);
|
|
271
|
+
if (weight >= 0 && weight <= 1) {
|
|
272
|
+
thenClause.weight = weight;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const reason = document.getElementById('thenReason').value.trim();
|
|
276
|
+
if (reason) {
|
|
277
|
+
thenClause.reason = reason;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Create rule object
|
|
281
|
+
const rule = {
|
|
282
|
+
id: ruleId,
|
|
283
|
+
if: ifCondition,
|
|
284
|
+
then: thenClause
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// Save or update
|
|
288
|
+
if (this.currentRuleIndex !== null) {
|
|
289
|
+
this.rules[this.currentRuleIndex] = rule;
|
|
290
|
+
} else {
|
|
291
|
+
this.rules.push(rule);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
this.closeModal();
|
|
295
|
+
this.renderRules();
|
|
296
|
+
this.updateJSONPreview();
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
closeModal() {
|
|
300
|
+
document.getElementById('ruleModal').classList.add('hidden');
|
|
301
|
+
this.currentRule = null;
|
|
302
|
+
this.currentRuleIndex = null;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
renderRules() {
|
|
306
|
+
const container = document.getElementById('rulesContainer');
|
|
307
|
+
|
|
308
|
+
if (this.rules.length === 0) {
|
|
309
|
+
container.innerHTML = '<p style="text-align: center; color: #6b7280; padding: 20px;">No rules yet. Click "Add Rule" to create one.</p>';
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
container.innerHTML = '';
|
|
314
|
+
|
|
315
|
+
this.rules.forEach((rule, index) => {
|
|
316
|
+
const ruleCard = document.createElement('div');
|
|
317
|
+
ruleCard.className = 'rule-card';
|
|
318
|
+
|
|
319
|
+
const conditionSummary = this.getConditionSummary(rule.if);
|
|
320
|
+
|
|
321
|
+
ruleCard.innerHTML = `
|
|
322
|
+
<div class="rule-header">
|
|
323
|
+
<span class="rule-id">${this.escapeHtml(rule.id)}</span>
|
|
324
|
+
<div class="rule-actions">
|
|
325
|
+
<button class="btn btn-sm btn-secondary edit-btn">Edit</button>
|
|
326
|
+
<button class="btn-remove delete-btn">×</button>
|
|
327
|
+
</div>
|
|
328
|
+
</div>
|
|
329
|
+
<div class="rule-summary">
|
|
330
|
+
IF: ${conditionSummary}<br>
|
|
331
|
+
THEN: ${this.escapeHtml(rule.then.decision)} (weight: ${rule.then.weight || 'default'})
|
|
332
|
+
</div>
|
|
333
|
+
`;
|
|
334
|
+
|
|
335
|
+
ruleCard.querySelector('.edit-btn').addEventListener('click', () => this.openRuleModal(index));
|
|
336
|
+
ruleCard.querySelector('.delete-btn').addEventListener('click', () => this.deleteRule(index));
|
|
337
|
+
|
|
338
|
+
container.appendChild(ruleCard);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
getConditionSummary(condition) {
|
|
343
|
+
if (condition.field) {
|
|
344
|
+
const valueText = condition.value !== undefined ? ` "${this.escapeHtml(JSON.stringify(condition.value))}"` : '';
|
|
345
|
+
return `${this.escapeHtml(condition.field)} ${condition.op}${valueText}`;
|
|
346
|
+
} else if (condition.all) {
|
|
347
|
+
return `ALL (${condition.all.length} conditions)`;
|
|
348
|
+
} else if (condition.any) {
|
|
349
|
+
return `ANY (${condition.any.length} conditions)`;
|
|
350
|
+
}
|
|
351
|
+
return 'unknown';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
deleteRule(index) {
|
|
355
|
+
if (confirm('Are you sure you want to delete this rule?')) {
|
|
356
|
+
this.rules.splice(index, 1);
|
|
357
|
+
this.renderRules();
|
|
358
|
+
this.updateJSONPreview();
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
updateJSONPreview() {
|
|
363
|
+
const version = document.getElementById('rulesetVersion').value || '1.0';
|
|
364
|
+
const ruleset = document.getElementById('rulesetName').value || 'my_ruleset';
|
|
365
|
+
|
|
366
|
+
const output = {
|
|
367
|
+
version: version,
|
|
368
|
+
ruleset: ruleset,
|
|
369
|
+
rules: this.rules
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
document.getElementById('jsonOutput').textContent = JSON.stringify(output, null, 2);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
async validateRules() {
|
|
376
|
+
const version = document.getElementById('rulesetVersion').value || '1.0';
|
|
377
|
+
const ruleset = document.getElementById('rulesetName').value || 'my_ruleset';
|
|
378
|
+
|
|
379
|
+
const payload = {
|
|
380
|
+
version: version,
|
|
381
|
+
ruleset: ruleset,
|
|
382
|
+
rules: this.rules
|
|
383
|
+
};
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
const response = await fetch('/api/validate', {
|
|
387
|
+
method: 'POST',
|
|
388
|
+
headers: {
|
|
389
|
+
'Content-Type': 'application/json'
|
|
390
|
+
},
|
|
391
|
+
body: JSON.stringify(payload)
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
const result = await response.json();
|
|
395
|
+
|
|
396
|
+
if (result.valid) {
|
|
397
|
+
this.showValidationSuccess();
|
|
398
|
+
} else {
|
|
399
|
+
this.showValidationErrors(result.errors);
|
|
400
|
+
}
|
|
401
|
+
} catch (error) {
|
|
402
|
+
console.error('Validation error:', error);
|
|
403
|
+
this.showValidationErrors(['Network error: Could not connect to validation server']);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
showValidationSuccess() {
|
|
408
|
+
const statusEl = document.getElementById('validationStatus');
|
|
409
|
+
const errorsEl = document.getElementById('validationErrors');
|
|
410
|
+
|
|
411
|
+
statusEl.className = 'validation-status success';
|
|
412
|
+
statusEl.querySelector('.status-message').textContent = 'All rules are valid!';
|
|
413
|
+
statusEl.classList.remove('hidden');
|
|
414
|
+
|
|
415
|
+
errorsEl.classList.add('hidden');
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
showValidationErrors(errors) {
|
|
419
|
+
const statusEl = document.getElementById('validationStatus');
|
|
420
|
+
const errorsEl = document.getElementById('validationErrors');
|
|
421
|
+
const errorList = document.getElementById('errorList');
|
|
422
|
+
|
|
423
|
+
statusEl.className = 'validation-status error';
|
|
424
|
+
statusEl.querySelector('.status-message').textContent = 'Validation failed. See errors below.';
|
|
425
|
+
statusEl.classList.remove('hidden');
|
|
426
|
+
|
|
427
|
+
errorList.innerHTML = '';
|
|
428
|
+
errors.forEach(error => {
|
|
429
|
+
const li = document.createElement('li');
|
|
430
|
+
li.textContent = error;
|
|
431
|
+
errorList.appendChild(li);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
errorsEl.classList.remove('hidden');
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
clearAll() {
|
|
438
|
+
if (confirm('Are you sure you want to clear all rules?')) {
|
|
439
|
+
this.rules = [];
|
|
440
|
+
this.renderRules();
|
|
441
|
+
this.updateJSONPreview();
|
|
442
|
+
document.getElementById('validationStatus').classList.add('hidden');
|
|
443
|
+
document.getElementById('validationErrors').classList.add('hidden');
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
copyJSON() {
|
|
448
|
+
const jsonText = document.getElementById('jsonOutput').textContent;
|
|
449
|
+
navigator.clipboard.writeText(jsonText).then(() => {
|
|
450
|
+
const btn = document.getElementById('copyBtn');
|
|
451
|
+
const originalText = btn.textContent;
|
|
452
|
+
btn.textContent = '✓ Copied!';
|
|
453
|
+
setTimeout(() => {
|
|
454
|
+
btn.textContent = originalText;
|
|
455
|
+
}, 2000);
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
downloadJSON() {
|
|
460
|
+
const jsonText = document.getElementById('jsonOutput').textContent;
|
|
461
|
+
const blob = new Blob([jsonText], { type: 'application/json' });
|
|
462
|
+
const url = URL.createObjectURL(blob);
|
|
463
|
+
const a = document.createElement('a');
|
|
464
|
+
a.href = url;
|
|
465
|
+
a.download = `rules_${new Date().getTime()}.json`;
|
|
466
|
+
a.click();
|
|
467
|
+
URL.revokeObjectURL(url);
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
importJSON(event) {
|
|
471
|
+
const file = event.target.files[0];
|
|
472
|
+
if (!file) return;
|
|
473
|
+
|
|
474
|
+
const reader = new FileReader();
|
|
475
|
+
reader.onload = (e) => {
|
|
476
|
+
try {
|
|
477
|
+
const data = JSON.parse(e.target.result);
|
|
478
|
+
|
|
479
|
+
if (data.rules && Array.isArray(data.rules)) {
|
|
480
|
+
this.rules = data.rules;
|
|
481
|
+
|
|
482
|
+
if (data.version) {
|
|
483
|
+
document.getElementById('rulesetVersion').value = data.version;
|
|
484
|
+
}
|
|
485
|
+
if (data.ruleset) {
|
|
486
|
+
document.getElementById('rulesetName').value = data.ruleset;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
this.renderRules();
|
|
490
|
+
this.updateJSONPreview();
|
|
491
|
+
alert('Rules imported successfully!');
|
|
492
|
+
} else {
|
|
493
|
+
alert('Invalid JSON format. Expected "rules" array.');
|
|
494
|
+
}
|
|
495
|
+
} catch (error) {
|
|
496
|
+
alert('Error parsing JSON: ' + error.message);
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
reader.readAsText(file);
|
|
500
|
+
|
|
501
|
+
// Reset input
|
|
502
|
+
event.target.value = '';
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
loadExample() {
|
|
506
|
+
const example = {
|
|
507
|
+
version: '1.0',
|
|
508
|
+
ruleset: 'example_approval_rules',
|
|
509
|
+
rules: [
|
|
510
|
+
{
|
|
511
|
+
id: 'high_priority_auto_approve',
|
|
512
|
+
if: {
|
|
513
|
+
all: [
|
|
514
|
+
{ field: 'priority', op: 'eq', value: 'high' },
|
|
515
|
+
{ field: 'user.role', op: 'eq', value: 'admin' }
|
|
516
|
+
]
|
|
517
|
+
},
|
|
518
|
+
then: {
|
|
519
|
+
decision: 'approve',
|
|
520
|
+
weight: 0.95,
|
|
521
|
+
reason: 'High priority request from admin'
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
{
|
|
525
|
+
id: 'low_amount_approve',
|
|
526
|
+
if: {
|
|
527
|
+
all: [
|
|
528
|
+
{ field: 'amount', op: 'lt', value: 1000 },
|
|
529
|
+
{ field: 'status', op: 'eq', value: 'active' }
|
|
530
|
+
]
|
|
531
|
+
},
|
|
532
|
+
then: {
|
|
533
|
+
decision: 'approve',
|
|
534
|
+
weight: 0.8,
|
|
535
|
+
reason: 'Low amount with active status'
|
|
536
|
+
}
|
|
537
|
+
},
|
|
538
|
+
{
|
|
539
|
+
id: 'missing_info_reject',
|
|
540
|
+
if: {
|
|
541
|
+
any: [
|
|
542
|
+
{ field: 'description', op: 'blank' },
|
|
543
|
+
{ field: 'assignee', op: 'blank' }
|
|
544
|
+
]
|
|
545
|
+
},
|
|
546
|
+
then: {
|
|
547
|
+
decision: 'needs_info',
|
|
548
|
+
weight: 0.7,
|
|
549
|
+
reason: 'Missing required information'
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
]
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
this.rules = example.rules;
|
|
556
|
+
document.getElementById('rulesetVersion').value = example.version;
|
|
557
|
+
document.getElementById('rulesetName').value = example.ruleset;
|
|
558
|
+
|
|
559
|
+
this.renderRules();
|
|
560
|
+
this.updateJSONPreview();
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
escapeHtml(text) {
|
|
564
|
+
const div = document.createElement('div');
|
|
565
|
+
div.textContent = String(text);
|
|
566
|
+
return div.innerHTML;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Initialize app when DOM is ready
|
|
571
|
+
document.addEventListener('DOMContentLoaded', () => {
|
|
572
|
+
window.ruleBuilder = new RuleBuilder();
|
|
573
|
+
|
|
574
|
+
// Update JSON on metadata changes
|
|
575
|
+
['rulesetVersion', 'rulesetName'].forEach(id => {
|
|
576
|
+
document.getElementById(id).addEventListener('input', () => {
|
|
577
|
+
window.ruleBuilder.updateJSONPreview();
|
|
578
|
+
});
|
|
579
|
+
});
|
|
580
|
+
});
|