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.
Files changed (44) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE.txt +21 -0
  3. data/README.md +1060 -0
  4. data/bin/decision_agent +104 -0
  5. data/lib/decision_agent/agent.rb +147 -0
  6. data/lib/decision_agent/audit/adapter.rb +9 -0
  7. data/lib/decision_agent/audit/logger_adapter.rb +27 -0
  8. data/lib/decision_agent/audit/null_adapter.rb +8 -0
  9. data/lib/decision_agent/context.rb +42 -0
  10. data/lib/decision_agent/decision.rb +51 -0
  11. data/lib/decision_agent/dsl/condition_evaluator.rb +133 -0
  12. data/lib/decision_agent/dsl/rule_parser.rb +36 -0
  13. data/lib/decision_agent/dsl/schema_validator.rb +275 -0
  14. data/lib/decision_agent/errors.rb +62 -0
  15. data/lib/decision_agent/evaluation.rb +52 -0
  16. data/lib/decision_agent/evaluators/base.rb +15 -0
  17. data/lib/decision_agent/evaluators/json_rule_evaluator.rb +51 -0
  18. data/lib/decision_agent/evaluators/static_evaluator.rb +31 -0
  19. data/lib/decision_agent/replay/replay.rb +147 -0
  20. data/lib/decision_agent/scoring/base.rb +19 -0
  21. data/lib/decision_agent/scoring/consensus.rb +40 -0
  22. data/lib/decision_agent/scoring/max_weight.rb +16 -0
  23. data/lib/decision_agent/scoring/threshold.rb +40 -0
  24. data/lib/decision_agent/scoring/weighted_average.rb +26 -0
  25. data/lib/decision_agent/version.rb +3 -0
  26. data/lib/decision_agent/web/public/app.js +580 -0
  27. data/lib/decision_agent/web/public/index.html +190 -0
  28. data/lib/decision_agent/web/public/styles.css +558 -0
  29. data/lib/decision_agent/web/server.rb +255 -0
  30. data/lib/decision_agent.rb +29 -0
  31. data/spec/agent_spec.rb +249 -0
  32. data/spec/api_contract_spec.rb +430 -0
  33. data/spec/audit_adapters_spec.rb +74 -0
  34. data/spec/comprehensive_edge_cases_spec.rb +1777 -0
  35. data/spec/context_spec.rb +84 -0
  36. data/spec/dsl_validation_spec.rb +648 -0
  37. data/spec/edge_cases_spec.rb +353 -0
  38. data/spec/examples/feedback_aware_evaluator_spec.rb +460 -0
  39. data/spec/json_rule_evaluator_spec.rb +587 -0
  40. data/spec/replay_edge_cases_spec.rb +699 -0
  41. data/spec/replay_spec.rb +210 -0
  42. data/spec/scoring_spec.rb +225 -0
  43. data/spec/spec_helper.rb +28 -0
  44. 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,3 @@
1
+ module DecisionAgent
2
+ VERSION = "0.1.1"
3
+ 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
+ });