pylitmus 1.0.0__tar.gz → 1.2.0__tar.gz
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.
- {pylitmus-1.0.0 → pylitmus-1.2.0}/PKG-INFO +1 -1
- {pylitmus-1.0.0 → pylitmus-1.2.0}/docs/flask-integration.md +11 -11
- {pylitmus-1.0.0 → pylitmus-1.2.0}/docs/quickstart.md +8 -8
- {pylitmus-1.0.0 → pylitmus-1.2.0}/pyproject.toml +1 -1
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/__init__.py +3 -2
- pylitmus-1.2.0/src/pylitmus/engine.py +427 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/factory.py +29 -7
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/integrations/flask/extension.py +51 -9
- pylitmus-1.2.0/src/pylitmus/rete/__init__.py +26 -0
- pylitmus-1.2.0/src/pylitmus/rete/compiler.py +356 -0
- pylitmus-1.2.0/src/pylitmus/rete/network.py +269 -0
- pylitmus-1.2.0/src/pylitmus/rete/nodes.py +318 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/types.py +29 -3
- pylitmus-1.2.0/tests/test_phase10_backward_compat.py +1421 -0
- pylitmus-1.2.0/tests/test_phase11_rete_spec.py +1154 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/tests/test_phase1_core.py +50 -21
- {pylitmus-1.0.0 → pylitmus-1.2.0}/tests/test_phase2_conditions.py +4 -4
- {pylitmus-1.0.0 → pylitmus-1.2.0}/tests/test_phase3_evaluators.py +5 -5
- {pylitmus-1.0.0 → pylitmus-1.2.0}/tests/test_phase4_strategies.py +10 -10
- {pylitmus-1.0.0 → pylitmus-1.2.0}/tests/test_phase5_storage.py +4 -4
- {pylitmus-1.0.0 → pylitmus-1.2.0}/tests/test_phase6_patterns.py +7 -7
- {pylitmus-1.0.0 → pylitmus-1.2.0}/tests/test_phase7_flask.py +38 -27
- {pylitmus-1.0.0 → pylitmus-1.2.0}/tests/test_phase8_factory.py +51 -14
- pylitmus-1.2.0/tests/test_phase9_decision_tiers.py +566 -0
- pylitmus-1.0.0/src/pylitmus/engine.py +0 -244
- {pylitmus-1.0.0 → pylitmus-1.2.0}/CHANGELOG.md +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/LICENSE +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/README.md +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/docs/api-reference.md +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/docs/rules-format.md +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/examples/basic_usage.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/examples/flask_app/app.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/examples/flask_app/requirements.txt +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/examples/flask_app/rules.yaml +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/conditions/__init__.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/conditions/base.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/conditions/builder.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/conditions/composite.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/conditions/simple.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/evaluators/__init__.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/evaluators/base.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/evaluators/factory.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/exceptions.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/integrations/__init__.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/integrations/flask/__init__.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/patterns/__init__.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/patterns/base.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/patterns/engine.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/patterns/exact.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/patterns/fuzzy.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/patterns/glob.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/patterns/range.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/patterns/regex.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/storage/__init__.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/storage/base.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/storage/cached.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/storage/database.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/storage/file.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/storage/memory.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/strategies/__init__.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/strategies/base.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/strategies/max.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/strategies/sum.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/src/pylitmus/strategies/weighted.py +0 -0
- {pylitmus-1.0.0 → pylitmus-1.2.0}/tests/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: pylitmus
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
4
4
|
Summary: A high-performance rules engine for Python - evaluate data against configurable rules and get clear verdicts
|
|
5
5
|
Project-URL: Homepage, https://github.com/yourorg/pylitmus
|
|
6
6
|
Project-URL: Documentation, https://pylitmus.readthedocs.io/
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# Flask Integration Guide
|
|
2
2
|
|
|
3
|
-
This guide covers integrating
|
|
3
|
+
This guide covers integrating pylitmus with Flask applications.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
pip install
|
|
8
|
+
pip install pylitmus[flask]
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
## Basic Setup
|
|
@@ -14,7 +14,7 @@ pip install cmap-rules-engine[flask]
|
|
|
14
14
|
|
|
15
15
|
```python
|
|
16
16
|
from flask import Flask
|
|
17
|
-
from
|
|
17
|
+
from pylitmus.integrations.flask import CmapRulesEngine
|
|
18
18
|
|
|
19
19
|
rules_engine = CmapRulesEngine()
|
|
20
20
|
|
|
@@ -35,7 +35,7 @@ def create_app():
|
|
|
35
35
|
|
|
36
36
|
```python
|
|
37
37
|
from flask import Flask
|
|
38
|
-
from
|
|
38
|
+
from pylitmus.integrations.flask import CmapRulesEngine
|
|
39
39
|
|
|
40
40
|
app = Flask(__name__)
|
|
41
41
|
app.config['CMAP_RULES_FILE'] = 'rules.yaml'
|
|
@@ -62,7 +62,7 @@ rules_engine = CmapRulesEngine(app)
|
|
|
62
62
|
|
|
63
63
|
```python
|
|
64
64
|
from flask import Flask, request, jsonify
|
|
65
|
-
from
|
|
65
|
+
from pylitmus.integrations.flask import CmapRulesEngine, get_engine
|
|
66
66
|
|
|
67
67
|
app = Flask(__name__)
|
|
68
68
|
app.config['CMAP_RULES_FILE'] = 'rules.yaml'
|
|
@@ -98,7 +98,7 @@ def evaluate():
|
|
|
98
98
|
|
|
99
99
|
```python
|
|
100
100
|
from flask import Blueprint, request, jsonify
|
|
101
|
-
from
|
|
101
|
+
from pylitmus.integrations.flask import get_engine
|
|
102
102
|
|
|
103
103
|
api = Blueprint('api', __name__)
|
|
104
104
|
|
|
@@ -201,7 +201,7 @@ rules:
|
|
|
201
201
|
|
|
202
202
|
```python
|
|
203
203
|
from flask import Flask, request, jsonify
|
|
204
|
-
from
|
|
204
|
+
from pylitmus.integrations.flask import CmapRulesEngine, get_engine
|
|
205
205
|
|
|
206
206
|
def create_app(config_class='config.Config'):
|
|
207
207
|
app = Flask(__name__)
|
|
@@ -326,7 +326,7 @@ curl -X POST http://localhost:5000/evaluate \
|
|
|
326
326
|
```python
|
|
327
327
|
from flask import Flask
|
|
328
328
|
from flask_sqlalchemy import SQLAlchemy
|
|
329
|
-
from
|
|
329
|
+
from pylitmus.integrations.flask import CmapRulesEngine
|
|
330
330
|
|
|
331
331
|
app = Flask(__name__)
|
|
332
332
|
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://localhost/mydb'
|
|
@@ -341,7 +341,7 @@ rules_engine = CmapRulesEngine(app)
|
|
|
341
341
|
|
|
342
342
|
```python
|
|
343
343
|
from flask import Flask
|
|
344
|
-
from
|
|
344
|
+
from pylitmus.integrations.flask import CmapRulesEngine
|
|
345
345
|
|
|
346
346
|
app = Flask(__name__)
|
|
347
347
|
|
|
@@ -357,8 +357,8 @@ rules_engine = CmapRulesEngine(app)
|
|
|
357
357
|
|
|
358
358
|
```python
|
|
359
359
|
from flask import Flask, jsonify
|
|
360
|
-
from
|
|
361
|
-
from
|
|
360
|
+
from pylitmus.integrations.flask import CmapRulesEngine, get_engine
|
|
361
|
+
from pylitmus import RuleEngineError, EvaluationError
|
|
362
362
|
|
|
363
363
|
app = Flask(__name__)
|
|
364
364
|
rules_engine = CmapRulesEngine(app)
|
|
@@ -1,27 +1,27 @@
|
|
|
1
1
|
# Quick Start Guide
|
|
2
2
|
|
|
3
|
-
This guide will help you get started with
|
|
3
|
+
This guide will help you get started with pylitmus in minutes.
|
|
4
4
|
|
|
5
5
|
## Installation
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
pip install
|
|
8
|
+
pip install pylitmus
|
|
9
9
|
```
|
|
10
10
|
|
|
11
11
|
For additional features:
|
|
12
12
|
|
|
13
13
|
```bash
|
|
14
14
|
# Database support (PostgreSQL, MySQL, SQLite)
|
|
15
|
-
pip install
|
|
15
|
+
pip install pylitmus[database]
|
|
16
16
|
|
|
17
17
|
# Redis caching
|
|
18
|
-
pip install
|
|
18
|
+
pip install pylitmus[redis]
|
|
19
19
|
|
|
20
20
|
# Flask integration
|
|
21
|
-
pip install
|
|
21
|
+
pip install pylitmus[flask]
|
|
22
22
|
|
|
23
23
|
# All extras
|
|
24
|
-
pip install
|
|
24
|
+
pip install pylitmus[all]
|
|
25
25
|
```
|
|
26
26
|
|
|
27
27
|
## Basic Usage
|
|
@@ -31,7 +31,7 @@ pip install cmap-rules-engine[all]
|
|
|
31
31
|
The simplest way to create a rules engine:
|
|
32
32
|
|
|
33
33
|
```python
|
|
34
|
-
from
|
|
34
|
+
from pylitmus import create_engine
|
|
35
35
|
|
|
36
36
|
# Simple in-memory engine
|
|
37
37
|
engine = create_engine()
|
|
@@ -40,7 +40,7 @@ engine = create_engine()
|
|
|
40
40
|
### Defining Rules in Python
|
|
41
41
|
|
|
42
42
|
```python
|
|
43
|
-
from
|
|
43
|
+
from pylitmus import create_engine, Rule, Severity
|
|
44
44
|
|
|
45
45
|
rules = [
|
|
46
46
|
Rule(
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pylitmus"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.2.0"
|
|
8
8
|
description = "A high-performance rules engine for Python - evaluate data against configurable rules and get clear verdicts"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = {text = "MIT"}
|
|
@@ -26,9 +26,9 @@ from .storage import (
|
|
|
26
26
|
RuleRepository,
|
|
27
27
|
)
|
|
28
28
|
from .strategies import MaxStrategy, ScoringStrategy, SumStrategy, WeightedStrategy
|
|
29
|
-
from .types import AssessmentResult, Operator, Rule, RuleResult, Severity
|
|
29
|
+
from .types import AssessmentResult, DecisionTier, Operator, Rule, RuleResult, Severity
|
|
30
30
|
|
|
31
|
-
__version__ = "1.
|
|
31
|
+
__version__ = "1.2.0"
|
|
32
32
|
|
|
33
33
|
__all__ = [
|
|
34
34
|
# Main
|
|
@@ -40,6 +40,7 @@ __all__ = [
|
|
|
40
40
|
"Rule",
|
|
41
41
|
"RuleResult",
|
|
42
42
|
"AssessmentResult",
|
|
43
|
+
"DecisionTier",
|
|
43
44
|
"Operator",
|
|
44
45
|
"Severity",
|
|
45
46
|
# Conditions
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Main RuleEngine class for evaluating data against configured rules.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from typing import TYPE_CHECKING, Any, Dict, List, Optional
|
|
8
|
+
|
|
9
|
+
from .exceptions import EvaluationError
|
|
10
|
+
from .types import AssessmentResult, DecisionTier, Rule, RuleResult
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from .rete import RETENetwork, RuleCompiler
|
|
14
|
+
from .storage.base import RuleRepository
|
|
15
|
+
from .strategies.base import ScoringStrategy
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RuleEngine:
|
|
21
|
+
"""
|
|
22
|
+
Main rules engine for evaluating data against configured rules.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
# Without decision tiers (returns None for decision)
|
|
26
|
+
engine = RuleEngine(
|
|
27
|
+
repository=DatabaseRuleRepository(db_url),
|
|
28
|
+
scoring_strategy=WeightedStrategy()
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# With user-defined decision tiers
|
|
32
|
+
engine = RuleEngine(
|
|
33
|
+
repository=DatabaseRuleRepository(db_url),
|
|
34
|
+
scoring_strategy=WeightedStrategy(),
|
|
35
|
+
decision_tiers=[
|
|
36
|
+
DecisionTier("AUTO_APPROVE", 0, 20, "Very low risk"),
|
|
37
|
+
DecisionTier("APPROVE", 20, 40, "Low risk"),
|
|
38
|
+
DecisionTier("SOFT_REVIEW", 40, 60, "Medium risk"),
|
|
39
|
+
DecisionTier("HARD_REVIEW", 60, 80, "Higher risk"),
|
|
40
|
+
DecisionTier("FLAG", 80, 95, "High risk"),
|
|
41
|
+
DecisionTier("AUTO_REJECT", 95, 101, "Very high risk"),
|
|
42
|
+
]
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
# With RETE algorithm for optimized evaluation (recommended for 50+ rules)
|
|
46
|
+
engine = RuleEngine(
|
|
47
|
+
repository=repo,
|
|
48
|
+
scoring_strategy=WeightedStrategy(),
|
|
49
|
+
use_rete=True # Enable RETE optimization
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
result = engine.evaluate(claim, context)
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(
|
|
56
|
+
self,
|
|
57
|
+
repository: "RuleRepository",
|
|
58
|
+
scoring_strategy: Optional["ScoringStrategy"] = None,
|
|
59
|
+
decision_tiers: Optional[List[DecisionTier]] = None,
|
|
60
|
+
condition_builder: Optional[Any] = None,
|
|
61
|
+
use_rete: bool = False,
|
|
62
|
+
):
|
|
63
|
+
"""
|
|
64
|
+
Initialize the rule engine.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
repository: Rule storage backend
|
|
68
|
+
scoring_strategy: Strategy for calculating scores
|
|
69
|
+
decision_tiers: User-defined decision tiers with score ranges.
|
|
70
|
+
If not provided, decision will be None.
|
|
71
|
+
condition_builder: Builder for creating conditions from dicts
|
|
72
|
+
use_rete: Enable RETE algorithm for optimized rule evaluation.
|
|
73
|
+
Recommended for rule sets with 50+ rules or significant
|
|
74
|
+
condition sharing. Default is False (use standard evaluation).
|
|
75
|
+
"""
|
|
76
|
+
self.repository = repository
|
|
77
|
+
self._scoring_strategy = scoring_strategy
|
|
78
|
+
self._decision_tiers = decision_tiers
|
|
79
|
+
self._condition_builder = condition_builder
|
|
80
|
+
self._rules_cache: Optional[List[Rule]] = None
|
|
81
|
+
|
|
82
|
+
# RETE configuration
|
|
83
|
+
self._use_rete = use_rete
|
|
84
|
+
self._rete_network: Optional["RETENetwork"] = None
|
|
85
|
+
self._rete_compiled = False
|
|
86
|
+
|
|
87
|
+
logger.info(f"RuleEngine initialized (use_rete={use_rete})")
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def use_rete(self) -> bool:
|
|
91
|
+
"""Check if RETE algorithm is enabled."""
|
|
92
|
+
return self._use_rete
|
|
93
|
+
|
|
94
|
+
@use_rete.setter
|
|
95
|
+
def use_rete(self, value: bool) -> None:
|
|
96
|
+
"""Enable or disable RETE algorithm."""
|
|
97
|
+
if value != self._use_rete:
|
|
98
|
+
self._use_rete = value
|
|
99
|
+
self._rete_network = None
|
|
100
|
+
self._rete_compiled = False
|
|
101
|
+
logger.info(f"RETE algorithm {'enabled' if value else 'disabled'}")
|
|
102
|
+
|
|
103
|
+
@property
|
|
104
|
+
def scoring_strategy(self) -> "ScoringStrategy":
|
|
105
|
+
"""Get the scoring strategy, creating default if needed."""
|
|
106
|
+
if self._scoring_strategy is None:
|
|
107
|
+
from .strategies import SumStrategy
|
|
108
|
+
|
|
109
|
+
self._scoring_strategy = SumStrategy()
|
|
110
|
+
return self._scoring_strategy
|
|
111
|
+
|
|
112
|
+
@property
|
|
113
|
+
def condition_builder(self) -> Any:
|
|
114
|
+
"""Get the condition builder, creating default if needed."""
|
|
115
|
+
if self._condition_builder is None:
|
|
116
|
+
from .conditions import ConditionBuilder
|
|
117
|
+
|
|
118
|
+
self._condition_builder = ConditionBuilder()
|
|
119
|
+
return self._condition_builder
|
|
120
|
+
|
|
121
|
+
def _ensure_rete_compiled(self, filters: Optional[Dict[str, Any]] = None) -> None:
|
|
122
|
+
"""
|
|
123
|
+
Ensure the RETE network is compiled with current rules.
|
|
124
|
+
|
|
125
|
+
Args:
|
|
126
|
+
filters: Filters to apply when fetching rules
|
|
127
|
+
"""
|
|
128
|
+
if self._rete_compiled and self._rete_network is not None:
|
|
129
|
+
return
|
|
130
|
+
|
|
131
|
+
from .rete import RuleCompiler
|
|
132
|
+
|
|
133
|
+
rules = self.repository.get_enabled(filters)
|
|
134
|
+
compiler = RuleCompiler()
|
|
135
|
+
self._rete_network = compiler.compile(rules)
|
|
136
|
+
self._rete_compiled = True
|
|
137
|
+
|
|
138
|
+
stats = self._rete_network.get_stats()
|
|
139
|
+
logger.info(
|
|
140
|
+
f"RETE network compiled: {stats['alpha_nodes']} alpha nodes, "
|
|
141
|
+
f"{stats['shared_conditions']} shared conditions, "
|
|
142
|
+
f"{stats['terminal_nodes']} rules"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
def evaluate(
|
|
146
|
+
self,
|
|
147
|
+
data: Dict[str, Any],
|
|
148
|
+
context: Optional[Dict[str, Any]] = None,
|
|
149
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
150
|
+
) -> AssessmentResult:
|
|
151
|
+
"""
|
|
152
|
+
Evaluate data against all applicable rules.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
data: Data to evaluate
|
|
156
|
+
context: Additional context for evaluation
|
|
157
|
+
filters: Filters to apply when fetching rules
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
AssessmentResult with score, decision, and triggered rules
|
|
161
|
+
"""
|
|
162
|
+
if self._use_rete:
|
|
163
|
+
return self._evaluate_with_rete(data, context, filters)
|
|
164
|
+
else:
|
|
165
|
+
return self._evaluate_standard(data, context, filters)
|
|
166
|
+
|
|
167
|
+
def _evaluate_with_rete(
|
|
168
|
+
self,
|
|
169
|
+
data: Dict[str, Any],
|
|
170
|
+
context: Optional[Dict[str, Any]] = None,
|
|
171
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
172
|
+
) -> AssessmentResult:
|
|
173
|
+
"""
|
|
174
|
+
Evaluate data using the RETE algorithm.
|
|
175
|
+
|
|
176
|
+
Args:
|
|
177
|
+
data: Data to evaluate
|
|
178
|
+
context: Additional context for evaluation
|
|
179
|
+
filters: Filters to apply when fetching rules
|
|
180
|
+
|
|
181
|
+
Returns:
|
|
182
|
+
AssessmentResult with score, decision, and triggered rules
|
|
183
|
+
"""
|
|
184
|
+
start_time = time.time()
|
|
185
|
+
context = context or {}
|
|
186
|
+
|
|
187
|
+
logger.debug(f"Starting RETE evaluation with {len(data)} data fields")
|
|
188
|
+
|
|
189
|
+
# Ensure RETE network is compiled
|
|
190
|
+
self._ensure_rete_compiled(filters)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
# Evaluate through RETE network
|
|
194
|
+
triggered_results = self._rete_network.evaluate(data)
|
|
195
|
+
|
|
196
|
+
# Convert severity strings back to Severity enum for consistency
|
|
197
|
+
from .types import Severity
|
|
198
|
+
|
|
199
|
+
for result in triggered_results:
|
|
200
|
+
if isinstance(result.severity, str):
|
|
201
|
+
try:
|
|
202
|
+
result.severity = Severity(result.severity)
|
|
203
|
+
except ValueError:
|
|
204
|
+
pass # Keep as string if not a valid Severity
|
|
205
|
+
|
|
206
|
+
except Exception as e:
|
|
207
|
+
logger.error(f"Error in RETE evaluation: {e}")
|
|
208
|
+
raise EvaluationError(f"RETE evaluation failed: {e}") from e
|
|
209
|
+
|
|
210
|
+
# Calculate total score using scoring strategy
|
|
211
|
+
total_score = self.scoring_strategy.calculate(triggered_results)
|
|
212
|
+
|
|
213
|
+
# Determine decision
|
|
214
|
+
decision = self._make_decision(total_score)
|
|
215
|
+
|
|
216
|
+
processing_time = (time.time() - start_time) * 1000
|
|
217
|
+
|
|
218
|
+
# Get total rules count from network stats
|
|
219
|
+
stats = self._rete_network.get_stats()
|
|
220
|
+
total_rules = stats["terminal_nodes"]
|
|
221
|
+
|
|
222
|
+
result = AssessmentResult(
|
|
223
|
+
total_score=total_score,
|
|
224
|
+
decision=decision,
|
|
225
|
+
triggered_rules=triggered_results,
|
|
226
|
+
all_rules_evaluated=total_rules,
|
|
227
|
+
processing_time_ms=processing_time,
|
|
228
|
+
metadata={
|
|
229
|
+
"context": context,
|
|
230
|
+
"filters": filters,
|
|
231
|
+
"evaluation_method": "rete",
|
|
232
|
+
"rete_stats": stats,
|
|
233
|
+
},
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
logger.info(
|
|
237
|
+
f"RETE evaluation complete: score={total_score}, decision={decision}, "
|
|
238
|
+
f"triggered={len(triggered_results)}/{total_rules}, time={processing_time:.2f}ms"
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
return result
|
|
242
|
+
|
|
243
|
+
def _evaluate_standard(
|
|
244
|
+
self,
|
|
245
|
+
data: Dict[str, Any],
|
|
246
|
+
context: Optional[Dict[str, Any]] = None,
|
|
247
|
+
filters: Optional[Dict[str, Any]] = None,
|
|
248
|
+
) -> AssessmentResult:
|
|
249
|
+
"""
|
|
250
|
+
Evaluate data using standard (non-RETE) algorithm.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
data: Data to evaluate
|
|
254
|
+
context: Additional context for evaluation
|
|
255
|
+
filters: Filters to apply when fetching rules
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
AssessmentResult with score, decision, and triggered rules
|
|
259
|
+
"""
|
|
260
|
+
start_time = time.time()
|
|
261
|
+
context = context or {}
|
|
262
|
+
|
|
263
|
+
logger.debug(f"Starting standard evaluation with {len(data)} data fields")
|
|
264
|
+
|
|
265
|
+
# Get applicable rules
|
|
266
|
+
rules = self.repository.get_enabled(filters)
|
|
267
|
+
|
|
268
|
+
triggered_results: List[RuleResult] = []
|
|
269
|
+
all_results: List[RuleResult] = []
|
|
270
|
+
|
|
271
|
+
for rule in rules:
|
|
272
|
+
try:
|
|
273
|
+
result = self.evaluate_rule(rule, data, context)
|
|
274
|
+
all_results.append(result)
|
|
275
|
+
|
|
276
|
+
if result.triggered:
|
|
277
|
+
triggered_results.append(result)
|
|
278
|
+
logger.debug(
|
|
279
|
+
f"Rule {rule.code} triggered with score {result.score}"
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
except Exception as e:
|
|
283
|
+
logger.error(f"Error evaluating rule {rule.code}: {e}")
|
|
284
|
+
raise EvaluationError(
|
|
285
|
+
f"Failed to evaluate rule {rule.code}: {e}"
|
|
286
|
+
) from e
|
|
287
|
+
|
|
288
|
+
# Calculate total score
|
|
289
|
+
total_score = self.scoring_strategy.calculate(triggered_results)
|
|
290
|
+
|
|
291
|
+
# Determine decision
|
|
292
|
+
decision = self._make_decision(total_score)
|
|
293
|
+
|
|
294
|
+
processing_time = (time.time() - start_time) * 1000
|
|
295
|
+
|
|
296
|
+
result = AssessmentResult(
|
|
297
|
+
total_score=total_score,
|
|
298
|
+
decision=decision,
|
|
299
|
+
triggered_rules=triggered_results,
|
|
300
|
+
all_rules_evaluated=len(rules),
|
|
301
|
+
processing_time_ms=processing_time,
|
|
302
|
+
metadata={
|
|
303
|
+
"context": context,
|
|
304
|
+
"filters": filters,
|
|
305
|
+
"evaluation_method": "standard",
|
|
306
|
+
},
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
logger.info(
|
|
310
|
+
f"Standard evaluation complete: score={total_score}, decision={decision}, "
|
|
311
|
+
f"triggered={len(triggered_results)}/{len(rules)}, time={processing_time:.2f}ms"
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
return result
|
|
315
|
+
|
|
316
|
+
def evaluate_rule(
|
|
317
|
+
self, rule: Rule, data: Dict[str, Any], context: Optional[Dict[str, Any]] = None
|
|
318
|
+
) -> RuleResult:
|
|
319
|
+
"""
|
|
320
|
+
Evaluate a single rule against data.
|
|
321
|
+
|
|
322
|
+
Args:
|
|
323
|
+
rule: Rule to evaluate
|
|
324
|
+
data: Data to evaluate against
|
|
325
|
+
context: Additional context
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
RuleResult indicating if rule triggered
|
|
329
|
+
"""
|
|
330
|
+
context = context or {}
|
|
331
|
+
|
|
332
|
+
# Check if rule is effective
|
|
333
|
+
if not rule.is_effective():
|
|
334
|
+
return RuleResult(
|
|
335
|
+
rule_code=rule.code,
|
|
336
|
+
rule_name=rule.name,
|
|
337
|
+
triggered=False,
|
|
338
|
+
score=0,
|
|
339
|
+
severity=rule.severity,
|
|
340
|
+
category=rule.category,
|
|
341
|
+
explanation="Rule is not currently effective",
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
# Build condition from rule definition
|
|
345
|
+
condition = self.condition_builder.build(rule.conditions)
|
|
346
|
+
|
|
347
|
+
# Evaluate condition
|
|
348
|
+
triggered = condition.evaluate(data)
|
|
349
|
+
|
|
350
|
+
return RuleResult(
|
|
351
|
+
rule_code=rule.code,
|
|
352
|
+
rule_name=rule.name,
|
|
353
|
+
triggered=triggered,
|
|
354
|
+
score=rule.score if triggered else 0,
|
|
355
|
+
severity=rule.severity,
|
|
356
|
+
category=rule.category,
|
|
357
|
+
explanation=rule.description if triggered else "Condition not met",
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
def _make_decision(self, score: int) -> Optional[str]:
|
|
361
|
+
"""
|
|
362
|
+
Make a decision based on the total score and configured decision tiers.
|
|
363
|
+
|
|
364
|
+
Args:
|
|
365
|
+
score: Total calculated score
|
|
366
|
+
|
|
367
|
+
Returns:
|
|
368
|
+
Decision string if a matching tier is found, None otherwise.
|
|
369
|
+
If no decision tiers are configured, returns None.
|
|
370
|
+
"""
|
|
371
|
+
if not self._decision_tiers:
|
|
372
|
+
return None
|
|
373
|
+
|
|
374
|
+
for tier in self._decision_tiers:
|
|
375
|
+
if tier.matches(score):
|
|
376
|
+
return tier.name
|
|
377
|
+
|
|
378
|
+
return None
|
|
379
|
+
|
|
380
|
+
def reload_rules(self) -> None:
|
|
381
|
+
"""Force reload rules from repository."""
|
|
382
|
+
self._rules_cache = None
|
|
383
|
+
|
|
384
|
+
# Invalidate RETE network to force recompilation
|
|
385
|
+
self._rete_network = None
|
|
386
|
+
self._rete_compiled = False
|
|
387
|
+
|
|
388
|
+
# If repository has cache, invalidate it
|
|
389
|
+
if hasattr(self.repository, "invalidate"):
|
|
390
|
+
self.repository.invalidate()
|
|
391
|
+
|
|
392
|
+
logger.info("Rules cache invalidated")
|
|
393
|
+
|
|
394
|
+
def get_rules(self, filters: Optional[Dict[str, Any]] = None) -> List[Rule]:
|
|
395
|
+
"""
|
|
396
|
+
Get all rules, optionally filtered.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
filters: Optional filters to apply
|
|
400
|
+
|
|
401
|
+
Returns:
|
|
402
|
+
List of rules
|
|
403
|
+
"""
|
|
404
|
+
return self.repository.get_all(filters)
|
|
405
|
+
|
|
406
|
+
def get_rule(self, code: str) -> Optional[Rule]:
|
|
407
|
+
"""
|
|
408
|
+
Get a specific rule by code.
|
|
409
|
+
|
|
410
|
+
Args:
|
|
411
|
+
code: Rule code
|
|
412
|
+
|
|
413
|
+
Returns:
|
|
414
|
+
Rule if found, None otherwise
|
|
415
|
+
"""
|
|
416
|
+
return self.repository.get_by_code(code)
|
|
417
|
+
|
|
418
|
+
def get_rete_stats(self) -> Optional[Dict[str, int]]:
|
|
419
|
+
"""
|
|
420
|
+
Get RETE network statistics.
|
|
421
|
+
|
|
422
|
+
Returns:
|
|
423
|
+
Dictionary with stats if RETE is enabled and compiled, None otherwise
|
|
424
|
+
"""
|
|
425
|
+
if self._rete_network is not None:
|
|
426
|
+
return self._rete_network.get_stats()
|
|
427
|
+
return None
|
|
@@ -4,7 +4,7 @@ Factory functions for creating RuleEngine instances with sensible defaults.
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
-
from typing import TYPE_CHECKING,
|
|
7
|
+
from typing import TYPE_CHECKING, List, Optional, Union
|
|
8
8
|
|
|
9
9
|
from .engine import RuleEngine
|
|
10
10
|
from .storage import (
|
|
@@ -15,6 +15,7 @@ from .storage import (
|
|
|
15
15
|
RuleRepository,
|
|
16
16
|
)
|
|
17
17
|
from .strategies import MaxStrategy, ScoringStrategy, SumStrategy, WeightedStrategy
|
|
18
|
+
from .types import DecisionTier
|
|
18
19
|
|
|
19
20
|
if TYPE_CHECKING:
|
|
20
21
|
from .types import Rule
|
|
@@ -34,7 +35,9 @@ def create_engine(
|
|
|
34
35
|
# Scoring
|
|
35
36
|
scoring_strategy: Union[str, ScoringStrategy] = "sum",
|
|
36
37
|
# Decision
|
|
37
|
-
|
|
38
|
+
decision_tiers: Optional[List[DecisionTier]] = None,
|
|
39
|
+
# RETE optimization
|
|
40
|
+
use_rete: bool = False,
|
|
38
41
|
) -> RuleEngine:
|
|
39
42
|
"""
|
|
40
43
|
Create a configured RuleEngine instance.
|
|
@@ -52,13 +55,17 @@ def create_engine(
|
|
|
52
55
|
cache_url: Redis URL (for 'redis' cache)
|
|
53
56
|
cache_ttl: Cache TTL in seconds
|
|
54
57
|
scoring_strategy: 'sum', 'weighted', 'max', or ScoringStrategy instance
|
|
55
|
-
|
|
58
|
+
decision_tiers: User-defined decision tiers with score ranges.
|
|
59
|
+
If not provided, decision will be None in results.
|
|
60
|
+
use_rete: Enable RETE algorithm for optimized rule evaluation.
|
|
61
|
+
Recommended for rule sets with 50+ rules or significant
|
|
62
|
+
condition sharing across rules. Default is False.
|
|
56
63
|
|
|
57
64
|
Returns:
|
|
58
65
|
Configured RuleEngine instance
|
|
59
66
|
|
|
60
67
|
Examples:
|
|
61
|
-
# Simple in-memory engine
|
|
68
|
+
# Simple in-memory engine (no decision tiers - decision will be None)
|
|
62
69
|
engine = create_engine()
|
|
63
70
|
|
|
64
71
|
# In-memory with predefined rules
|
|
@@ -78,10 +85,24 @@ def create_engine(
|
|
|
78
85
|
rules_file='./rules/fraud_rules.yaml'
|
|
79
86
|
)
|
|
80
87
|
|
|
81
|
-
# With
|
|
88
|
+
# With user-defined decision tiers
|
|
82
89
|
engine = create_engine(
|
|
83
90
|
scoring_strategy='weighted',
|
|
84
|
-
|
|
91
|
+
decision_tiers=[
|
|
92
|
+
DecisionTier("AUTO_APPROVE", 0, 20, "Very low risk"),
|
|
93
|
+
DecisionTier("APPROVE", 20, 40, "Low risk"),
|
|
94
|
+
DecisionTier("SOFT_REVIEW", 40, 60, "Medium risk"),
|
|
95
|
+
DecisionTier("HARD_REVIEW", 60, 80, "Higher risk"),
|
|
96
|
+
DecisionTier("FLAG", 80, 95, "High risk"),
|
|
97
|
+
DecisionTier("AUTO_REJECT", 95, 101, "Very high risk"),
|
|
98
|
+
]
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
# With RETE algorithm optimization (for large rule sets)
|
|
102
|
+
engine = create_engine(
|
|
103
|
+
storage_backend='database',
|
|
104
|
+
database_url='postgresql://localhost/mydb',
|
|
105
|
+
use_rete=True # Enable RETE for performance
|
|
85
106
|
)
|
|
86
107
|
"""
|
|
87
108
|
|
|
@@ -112,7 +133,8 @@ def create_engine(
|
|
|
112
133
|
return RuleEngine(
|
|
113
134
|
repository=repo,
|
|
114
135
|
scoring_strategy=strategy,
|
|
115
|
-
|
|
136
|
+
decision_tiers=decision_tiers,
|
|
137
|
+
use_rete=use_rete,
|
|
116
138
|
)
|
|
117
139
|
|
|
118
140
|
|