File size: 3,171 Bytes
82f262a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
"""
RuleEngine - deterministic production rules (IF-THEN) for guardrails.
Rules are data (JSON), not code. Evaluated on raw strings - zero model calls.

Predicate tree ("if"):
    {"regex": str} | {"contains": [str,...]} | {"length_gt": n} |
    {"token_count_gt": n} | {"and": [...]} | {"or": [...]} | {"not": {...}}

Action ("then"):
    {"action": "allow"|"warn"|"block"|"mask",
     "reply": str, "mask_pattern": str, "mask_replacement": str}
"""

import json
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Dict, List, Optional


@dataclass
class RuleDecision:
    action: str = "allow"  # allow | warn | block | mask
    reply: Optional[str] = None
    masked: Optional[str] = None
    rule_id: Optional[str] = None


def _match_predicate(node: Dict[str, Any], text: str) -> bool:
    if "regex" in node:
        return re.search(node["regex"], text) is not None
    if "contains" in node:
        vals = node["contains"]
        if isinstance(vals, str):
            vals = [vals]
        return any(v in text for v in vals)
    if "all_contains" in node:
        vals = node["all_contains"]
        if isinstance(vals, str):
            vals = [vals]
        return all(v in text for v in vals)
    if "length_gt" in node:
        return len(text) > int(node["length_gt"])
    if "token_count_gt" in node:
        return len(text.split()) > int(node["token_count_gt"])
    if "and" in node:
        return all(_match_predicate(n, text) for n in node["and"])
    if "or" in node:
        return any(_match_predicate(n, text) for n in node["or"])
    if "not" in node:
        return not _match_predicate(node["not"], text)
    return False


class RuleEngine:
    def __init__(self, rules_path: Optional[str] = None):
        self.rules: List[Dict[str, Any]] = []
        if rules_path:
            self.load(rules_path)

    def load(self, rules_path: str):
        path = Path(rules_path)
        data = json.loads(path.read_text(encoding="utf-8"))
        rules = data if isinstance(data, list) else data.get("rules", [])
        self.rules = sorted(
            rules, key=lambda r: -int(r.get("priority", 0))
        )
        return len(self.rules)

    def eval(self, text: str, phase: str = "in") -> RuleDecision:
        for rule in self.rules:
            if rule.get("phase", "in") != phase:
                continue
            if _match_predicate(rule["if"], text):
                then = rule.get("then", {})
                action = then.get("action", "allow")
                reply = then.get("reply")
                if action == "mask":
                    pattern = then.get("mask_pattern")
                    replacement = then.get("mask_replacement", "[REDACTED]")
                    masked = re.sub(pattern, replacement, text) if pattern else text
                    return RuleDecision(
                        action="mask", reply=reply, masked=masked,
                        rule_id=rule.get("id"),
                    )
                return RuleDecision(
                    action=action, reply=reply, rule_id=rule.get("id")
                )
        return RuleDecision(action="allow")