1280 lines
54 KiB
Python
1280 lines
54 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""
|
||
pbl_compiler.script_mapping —— M3b 第12章:event → condition → response (ECR) 到
|
||
script_engine `script_type=1` 规则 JSON 的确定性映射。
|
||
|
||
设计铁律(与 M3a canonical.py 一致):
|
||
1. **确定性**:同一蓝图版本快照 → 逐字节相同的规则 JSON。
|
||
- `json.dumps(..., sort_keys=True, separators=(',', ':'), ensure_ascii=False)`(零空白)
|
||
- 剥离易变字段(created_at / uuid / timestamp ...)
|
||
- 顶层规则按 (-priority, rule_id) 稳定排序;rule_id 缺失时由内容哈希派生
|
||
2. **fail-closed**:tenant_id 缺失、未知 action(strict 模式)、操作数缺失 → 记 issue 并阻断导出,
|
||
绝不静默产出半成品规则。
|
||
3. **纯函数层**:本文件不做任何 DB / IO,全部可离线单测(scripts/test_m3b_mapping.py)。
|
||
落库与产物导出在 exporter.py。
|
||
|
||
规则 JSON(script_type=1)契约 v1.0:
|
||
{
|
||
"schema_version": "1.0",
|
||
"rule_id": "...", # 稳定主键,来源 id 或内容哈希派生
|
||
"source_id": "...", # 蓝图子对象原始 id(可空)
|
||
"name": "...",
|
||
"description": "...",
|
||
"priority": 100, # 大者先执行
|
||
"enabled": true,
|
||
"event": {"kind": "event", "type": "...", "source": "...", "match": {...}},
|
||
"condition": {"kind": "all|any|not|compare|expr|always", ...},
|
||
"responses": [{"kind": "action", "action": "...", "target": {...}, "params": {...},
|
||
"delay_ms": 0, "repeat": 1}],
|
||
"limits": {"cooldown_ms": 0, "max_triggers": 0}, # 0 = 不限
|
||
"tags": ["..."],
|
||
"origin": {"blueprint_id": "...", "blueprint_version": "...",
|
||
"sub_object_id": "...", "source_index": 0}
|
||
}
|
||
"""
|
||
|
||
import hashlib
|
||
import json
|
||
import re
|
||
|
||
__all__ = [
|
||
'RULE_SCHEMA_VERSION',
|
||
'CANONICAL_OPS',
|
||
'OP_ALIASES',
|
||
'VOLATILE_KEYS',
|
||
'CompileError',
|
||
'Issue',
|
||
'canonical_dumps',
|
||
'sha256_text',
|
||
'strip_volatile',
|
||
'normalize_operand',
|
||
'normalize_condition',
|
||
'normalize_event',
|
||
'normalize_responses',
|
||
'normalize_ecr',
|
||
'iter_ecr_sources',
|
||
'build_rules',
|
||
'validate_rule',
|
||
'validate_rules',
|
||
'sort_rules',
|
||
'canonical_rules_json',
|
||
'rules_hash',
|
||
'build_rules_manifest',
|
||
'diff_rules',
|
||
]
|
||
|
||
RULE_SCHEMA_VERSION = '1.0'
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 错误与 issue
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class CompileError(Exception):
|
||
"""编译期硬错误(fail-closed)。code 用于前端/日志定位。"""
|
||
|
||
def __init__(self, code, message, detail=None):
|
||
super().__init__('[%s] %s' % (code, message))
|
||
self.code = code
|
||
self.message = message
|
||
self.detail = detail or {}
|
||
|
||
|
||
def Issue(severity, code, message, rule_id='', path=''):
|
||
"""统一 issue 结构(dict,便于 JSON 序列化)。severity: error|warning|info"""
|
||
return {
|
||
'severity': severity,
|
||
'code': code,
|
||
'message': message,
|
||
'rule_id': rule_id or '',
|
||
'path': path or '',
|
||
}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 确定性序列化 / 哈希 / 易变字段剥离
|
||
# ---------------------------------------------------------------------------
|
||
|
||
VOLATILE_KEYS = frozenset([
|
||
'created_at', 'updated_at', 'create_time', 'update_time', 'created_by', 'updated_by',
|
||
'creator', 'modifier', 'timestamp', 'ts', 'uuid', '_id', 'trace_id', 'request_id',
|
||
'generated_at', 'expire_at', 'deleted_at', 'revision', 'etag',
|
||
])
|
||
|
||
|
||
def canonical_dumps(obj):
|
||
"""确定性 JSON:sort_keys + 零空白 + 非 ASCII 原样。优先复用 M3a canonical 实现。"""
|
||
try: # pragma: no cover - 依赖同包 canonical.py(M3a 产物)
|
||
from . import canonical as _c
|
||
for fn in ('canonical_dumps', 'dumps_canonical', 'canonical_json'):
|
||
f = getattr(_c, fn, None)
|
||
if callable(f):
|
||
return f(obj)
|
||
except Exception:
|
||
pass
|
||
return json.dumps(obj, ensure_ascii=False, sort_keys=True, separators=(',', ':'))
|
||
|
||
|
||
def sha256_text(text):
|
||
if isinstance(text, str):
|
||
text = text.encode('utf-8')
|
||
return hashlib.sha256(text).hexdigest()
|
||
|
||
|
||
def strip_volatile(obj, depth=0):
|
||
"""递归剥离易变字段;list 保序(顺序是语义的一部分),dict 只删键不改值语义。"""
|
||
if depth > 32:
|
||
return obj
|
||
if isinstance(obj, dict):
|
||
out = {}
|
||
for k, v in obj.items():
|
||
ks = str(k)
|
||
if ks in VOLATILE_KEYS or ks.startswith('_volatile'):
|
||
continue
|
||
out[ks] = strip_volatile(v, depth + 1)
|
||
return out
|
||
if isinstance(obj, (list, tuple)):
|
||
return [strip_volatile(v, depth + 1) for v in obj]
|
||
return obj
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 标量工具
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_SNAKE_RE_1 = re.compile(r'(.)([A-Z][a-z]+)')
|
||
_SNAKE_RE_2 = re.compile(r'([a-z0-9])([A-Z])')
|
||
_NON_WORD_RE = re.compile(r'[^0-9a-zA-Z\u4e00-\u9fa5]+')
|
||
|
||
|
||
def to_snake(name):
|
||
"""`onEnter Scene` / `OnEnterScene` → `on_enter_scene`(确定性归一)。"""
|
||
if name is None:
|
||
return ''
|
||
s = str(name).strip()
|
||
if not s:
|
||
return ''
|
||
s = _SNAKE_RE_1.sub(r'\1_\2', s)
|
||
s = _SNAKE_RE_2.sub(r'\1_\2', s)
|
||
s = _NON_WORD_RE.sub('_', s).strip('_')
|
||
s = re.sub(r'_+', '_', s)
|
||
return s.lower()
|
||
|
||
|
||
def first_str(d, keys, default=''):
|
||
for k in keys:
|
||
if isinstance(d, dict) and d.get(k) not in (None, ''):
|
||
return str(d.get(k)).strip()
|
||
return default
|
||
|
||
|
||
def to_int(v, default=0):
|
||
try:
|
||
if v is None or v == '':
|
||
return int(default)
|
||
if isinstance(v, bool):
|
||
return int(v)
|
||
return int(float(v))
|
||
except (TypeError, ValueError):
|
||
return int(default)
|
||
|
||
|
||
def to_bool(v, default=True):
|
||
if v is None:
|
||
return bool(default)
|
||
if isinstance(v, bool):
|
||
return v
|
||
if isinstance(v, (int, float)):
|
||
return v != 0
|
||
s = str(v).strip().lower()
|
||
if s in ('1', 'true', 'yes', 'y', 'on', 'enabled', 'active', '是'):
|
||
return True
|
||
if s in ('0', 'false', 'no', 'n', 'off', 'disabled', 'inactive', '否'):
|
||
return False
|
||
return bool(default)
|
||
|
||
|
||
def to_number_or_raw(v):
|
||
"""常量值归一:数字字符串转数字(保证 1 与 '1' 编译结果一致),其余原样。"""
|
||
if isinstance(v, bool) or v is None:
|
||
return v
|
||
if isinstance(v, (int, float)):
|
||
return v
|
||
if isinstance(v, str):
|
||
s = v.strip()
|
||
if re.fullmatch(r'-?\d+', s):
|
||
return int(s)
|
||
if re.fullmatch(r'-?\d+\.\d+', s):
|
||
return float(s)
|
||
if s.lower() in ('true', 'false'):
|
||
return s.lower() == 'true'
|
||
if s.lower() in ('null', 'none'):
|
||
return None
|
||
return v
|
||
return v
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 操作符白名单(script_engine script_type=1 求值器契约)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
OP_ALIASES = {
|
||
'==': 'eq', '=': 'eq', 'eq': 'eq', 'equal': 'eq', 'equals': 'eq', 'is': 'eq', '相同': 'eq',
|
||
'!=': 'ne', '<>': 'ne', 'ne': 'ne', 'noteq': 'ne', 'not_equal': 'ne', 'is_not': 'ne',
|
||
'>': 'gt', 'gt': 'gt', 'greater_than': 'gt', '大于': 'gt',
|
||
'>=': 'gte', '=>': 'gte', 'gte': 'gte', 'ge': 'gte', 'greater_or_equal': 'gte', '大于等于': 'gte',
|
||
'<': 'lt', 'lt': 'lt', 'less_than': 'lt', '小于': 'lt',
|
||
'<=': 'lte', '=<': 'lte', 'lte': 'lte', 'le': 'lte', 'less_or_equal': 'lte', '小于等于': 'lte',
|
||
'in': 'in', 'contains': 'contains', 'has': 'contains', '包含': 'contains',
|
||
'not_in': 'not_in', 'nin': 'not_in', 'notcontains': 'not_contains',
|
||
'not_contains': 'not_contains',
|
||
'startswith': 'startswith', 'endswith': 'endswith',
|
||
'matches': 'matches', 'regex': 'matches', 'regexp': 'matches',
|
||
'is_true': 'is_true', 'istrue': 'is_true', 'true': 'is_true',
|
||
'is_false': 'is_false', 'isfalse': 'is_false', 'false': 'is_false',
|
||
'exists': 'exists', 'not_exists': 'not_exists', 'notexists': 'not_exists',
|
||
'between': 'between',
|
||
}
|
||
|
||
# 一元 / 二元 / 三元操作符
|
||
OP_ARITY = {
|
||
'eq': 2, 'ne': 2, 'gt': 2, 'gte': 2, 'lt': 2, 'lte': 2,
|
||
'in': 2, 'not_in': 2, 'contains': 2, 'not_contains': 2,
|
||
'startswith': 2, 'endswith': 2, 'matches': 2, 'between': 3,
|
||
'is_true': 1, 'is_false': 1, 'exists': 1, 'not_exists': 1,
|
||
}
|
||
|
||
CANONICAL_OPS = tuple(sorted(OP_ARITY.keys()))
|
||
|
||
# 符号操作符(长者优先,直接子串扫描)
|
||
_SYMBOL_OPS = sorted([a for a in OP_ALIASES if not a[0].isalnum()], key=len, reverse=True)
|
||
# 字母操作符(必须词边界匹配,避免 `remaining` 里的 `in` 误命中)
|
||
_WORD_OPS = sorted([a for a in OP_ALIASES if a[0].isalnum()], key=len, reverse=True)
|
||
_WORD_OP_RE = re.compile(
|
||
r'(?<![0-9A-Za-z_])(' + '|'.join(re.escape(o) for o in _WORD_OPS) + r')(?![0-9A-Za-z_])')
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 操作数(operand)归一
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# 剥离前缀(名字本身不含命名空间)
|
||
_VAR_STRIP_PREFIXES = ('var:', 'variable:', '$', '@')
|
||
# 命名空间前缀(保留完整路径,运行时按路径求值)
|
||
_VAR_NS_PREFIXES = ('vars.', 'state.', 'g.', 'global.', 'local.', 'self.')
|
||
|
||
|
||
def normalize_operand(value, issues, path, rule_id=''):
|
||
"""把任意写法的操作数归一为 {kind, ...}。
|
||
|
||
kind:
|
||
var {kind:'var', name}
|
||
entity_attr {kind:'entity_attr', entity, attr}
|
||
event_field {kind:'event_field', field}
|
||
const {kind:'const', value}
|
||
list {kind:'list', items:[operand...]}
|
||
"""
|
||
if isinstance(value, dict):
|
||
raw_kind = str(value.get('kind') or value.get('type') or '').strip().lower()
|
||
if raw_kind in ('var', 'variable', 'varref'):
|
||
name = first_str(value, ('name', 'var', 'field', 'key', 'path'))
|
||
if not name:
|
||
issues.append(Issue('error', 'E_COND_OPERAND', '变量操作数缺少 name', rule_id, path))
|
||
return {'kind': 'var', 'name': name}
|
||
if raw_kind in ('entity_attr', 'attr', 'attribute', 'property', 'prop'):
|
||
return {
|
||
'kind': 'entity_attr',
|
||
'entity': first_str(value, ('entity', 'entity_id', 'target', 'object')),
|
||
'attr': first_str(value, ('attr', 'name', 'property', 'field')),
|
||
}
|
||
if raw_kind in ('event_field', 'event', 'payload', 'trigger_field'):
|
||
return {'kind': 'event_field', 'field': first_str(value, ('field', 'name', 'key'))}
|
||
if raw_kind in ('const', 'constant', 'literal', 'value'):
|
||
return {'kind': 'const', 'value': to_number_or_raw(value.get('value', value.get('v')))}
|
||
if raw_kind in ('list', 'array'):
|
||
items = value.get('items') or value.get('values') or []
|
||
if not isinstance(items, (list, tuple)):
|
||
items = [items]
|
||
return {'kind': 'list',
|
||
'items': [normalize_operand(i, issues, path + '.items', rule_id) for i in items]}
|
||
# 无 kind 的裸 dict:按常见键推断
|
||
if 'field' in value or 'var' in value or 'name' in value:
|
||
name = first_str(value, ('field', 'var', 'name', 'key', 'path'))
|
||
if value.get('entity') or value.get('entity_id'):
|
||
return {'kind': 'entity_attr',
|
||
'entity': first_str(value, ('entity', 'entity_id')),
|
||
'attr': name}
|
||
return {'kind': 'var', 'name': name}
|
||
if 'value' in value:
|
||
return {'kind': 'const', 'value': to_number_or_raw(value.get('value'))}
|
||
issues.append(Issue('warning', 'W_COND_OPERAND_SHAPE',
|
||
'无法识别的操作数结构,按常量对象处理', rule_id, path))
|
||
return {'kind': 'const', 'value': strip_volatile(value)}
|
||
|
||
if isinstance(value, (list, tuple)):
|
||
return {'kind': 'list',
|
||
'items': [normalize_operand(i, issues, path + '.items', rule_id) for i in value]}
|
||
|
||
if isinstance(value, str):
|
||
s = value.strip()
|
||
for p in _VAR_STRIP_PREFIXES:
|
||
if s.startswith(p) and len(s) > len(p):
|
||
return {'kind': 'var', 'name': s[len(p):].strip().strip('{}')}
|
||
if s.startswith('{{') and s.endswith('}}'):
|
||
return {'kind': 'var', 'name': s[2:-2].strip()}
|
||
for p in _VAR_NS_PREFIXES:
|
||
if s.startswith(p) and len(s) > len(p):
|
||
return {'kind': 'var', 'name': s}
|
||
return {'kind': 'const', 'value': to_number_or_raw(value)}
|
||
|
||
return {'kind': 'const', 'value': to_number_or_raw(value)}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# condition 归一(递归布尔树)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_ALWAYS = {'kind': 'always'}
|
||
|
||
|
||
_IDENT_RE = re.compile(r'^[A-Za-z_\u4e00-\u9fa5][0-9A-Za-z_\.\u4e00-\u9fa5\[\]\'\"]*$')
|
||
|
||
|
||
def _operand_from_expr_token(token, issues, path, rule_id):
|
||
"""字符串表达式里的操作数:裸标识符 → var,其余走通用归一。"""
|
||
t = str(token).strip().strip('()')
|
||
if _IDENT_RE.match(t) and not re.fullmatch(r'-?\d+(\.\d+)?', t):
|
||
return {'kind': 'var', 'name': t}
|
||
return normalize_operand(t, issues, path, rule_id)
|
||
|
||
|
||
def _parse_string_expr(text, issues, path, rule_id):
|
||
"""尽力解析 `score >= 60` / `state.done == true` 形式的字符串条件。"""
|
||
s = str(text).strip()
|
||
if not s:
|
||
return dict(_ALWAYS)
|
||
low = s.lower()
|
||
if low in ('true', 'always', '1', '是'):
|
||
return dict(_ALWAYS)
|
||
if low in ('false', 'never', '0', '否'):
|
||
return {'kind': 'not', 'item': dict(_ALWAYS)}
|
||
candidates = []
|
||
for op in _SYMBOL_OPS:
|
||
idx = s.find(op)
|
||
while idx > 0:
|
||
candidates.append((idx, op))
|
||
idx = s.find(op, idx + 1)
|
||
for m in _WORD_OP_RE.finditer(s):
|
||
if m.start() > 0:
|
||
candidates.append((m.start(), m.group(1)))
|
||
# 取最靠左的操作符;同位置取最长(`>=` 优于 `>`)
|
||
candidates.sort(key=lambda c: (c[0], -len(c[1])))
|
||
for idx, op in candidates:
|
||
left = s[:idx].strip()
|
||
right = s[idx + len(op):].strip()
|
||
if not left:
|
||
continue
|
||
canon = OP_ALIASES.get(op.lower()) or OP_ALIASES.get(op)
|
||
if not canon:
|
||
continue
|
||
arity = OP_ARITY.get(canon, 2)
|
||
if arity == 1:
|
||
if right:
|
||
continue
|
||
return {'kind': 'compare', 'op': canon,
|
||
'left': _operand_from_expr_token(left, issues, path + '.left', rule_id)}
|
||
if not right:
|
||
continue
|
||
return {
|
||
'kind': 'compare',
|
||
'op': canon,
|
||
'left': _operand_from_expr_token(left, issues, path + '.left', rule_id),
|
||
'right': _operand_from_expr_token(right, issues, path + '.right', rule_id),
|
||
}
|
||
issues.append(Issue('warning', 'W_COND_EXPR_UNPARSED',
|
||
'字符串条件未能解析为结构化比较,按 expr 原样保留: %s' % s, rule_id, path))
|
||
return {'kind': 'expr', 'text': s}
|
||
|
||
|
||
def normalize_condition(raw, issues, path='condition', rule_id=''):
|
||
"""归一条件树。返回 None 表示「无条件」(调用方替换为 always)。"""
|
||
if raw is None or raw == '' or raw == [] or raw == {}:
|
||
return None
|
||
|
||
if isinstance(raw, str):
|
||
return _parse_string_expr(raw, issues, path, rule_id)
|
||
|
||
if isinstance(raw, (list, tuple)):
|
||
items = [normalize_condition(x, issues, '%s[%d]' % (path, i), rule_id)
|
||
for i, x in enumerate(raw)]
|
||
items = [i for i in items if i is not None]
|
||
if not items:
|
||
return None
|
||
if len(items) == 1:
|
||
return items[0]
|
||
return {'kind': 'all', 'items': items}
|
||
|
||
if not isinstance(raw, dict):
|
||
issues.append(Issue('warning', 'W_COND_SHAPE', '条件类型不支持,按常量真处理', rule_id, path))
|
||
return dict(_ALWAYS)
|
||
|
||
kind = str(raw.get('kind') or raw.get('type') or '').strip().lower()
|
||
|
||
# —— GD(M3a gd_builder.build_events)形态:all_of / any_of / none_of 字符串表达式数组 ——
|
||
for key, comb in (('all_of', 'all'), ('allof', 'all'), ('and', 'all'),
|
||
('any_of', 'any'), ('anyof', 'any'), ('or', 'any'),
|
||
('none_of', 'not'), ('noneof', 'not'), ('nor', 'not')):
|
||
if key in raw and isinstance(raw.get(key), (list, tuple, str, dict)):
|
||
val = raw[key]
|
||
items_raw = val if isinstance(val, (list, tuple)) else [val]
|
||
items = [normalize_condition(x, issues, '%s.%s[%d]' % (path, key, i), rule_id)
|
||
for i, x in enumerate(items_raw)]
|
||
items = [i for i in items if i is not None]
|
||
if not items:
|
||
return dict(_ALWAYS)
|
||
node = items[0] if len(items) == 1 else {'kind': comb if comb != 'not' else 'all',
|
||
'items': items}
|
||
if comb == 'not':
|
||
node = {'kind': 'not',
|
||
'item': (items[0] if len(items) == 1
|
||
else {'kind': 'any', 'items': items})}
|
||
# 与其它键并存时(如 all_of + min_count)合并为 all
|
||
rest = {k: v for k, v in raw.items()
|
||
if str(k).strip().lower().replace('_', '') != key.replace('_', '')}
|
||
if rest:
|
||
extra = normalize_condition(rest, issues, path + '.rest', rule_id)
|
||
if extra is not None and extra != dict(_ALWAYS):
|
||
node = {'kind': 'all', 'items': [node, extra]}
|
||
return node
|
||
|
||
# —— 计数型条件(GD evidence: {'min_count': N})——
|
||
for key, op in (('min_count', 'gte'), ('max_count', 'lte'), ('count', 'eq'),
|
||
('min', 'gte'), ('max', 'lte')):
|
||
if key in raw and raw.get(key) not in (None, ''):
|
||
subject = raw.get('subject') or raw.get('field') or raw.get('var') or key
|
||
node = {'kind': 'compare', 'op': op,
|
||
'left': normalize_operand(subject, issues, path + '.left', rule_id),
|
||
'right': normalize_operand(raw.get(key), issues, path + '.right', rule_id)}
|
||
rest = {k: v for k, v in raw.items()
|
||
if str(k).strip().lower() not in (key, 'subject', 'field', 'var')}
|
||
if rest:
|
||
extra = normalize_condition(rest, issues, path + '.rest', rule_id)
|
||
if extra is not None and extra != dict(_ALWAYS):
|
||
node = {'kind': 'all', 'items': [node, extra]}
|
||
return node
|
||
|
||
# 布尔组合子
|
||
if kind in ('all', 'and', 'every'):
|
||
children = raw.get('items') or raw.get('conditions') or raw.get('all') or raw.get('children') or []
|
||
items = [normalize_condition(c, issues, '%s.all[%d]' % (path, i), rule_id)
|
||
for i, c in enumerate(children if isinstance(children, (list, tuple)) else [children])]
|
||
items = [i for i in items if i is not None]
|
||
if not items:
|
||
return dict(_ALWAYS)
|
||
return {'kind': 'all', 'items': items} if len(items) > 1 else items[0]
|
||
|
||
if kind in ('any', 'or', 'some'):
|
||
children = raw.get('items') or raw.get('conditions') or raw.get('any') or raw.get('children') or []
|
||
items = [normalize_condition(c, issues, '%s.any[%d]' % (path, i), rule_id)
|
||
for i, c in enumerate(children if isinstance(children, (list, tuple)) else [children])]
|
||
items = [i for i in items if i is not None]
|
||
if not items:
|
||
return dict(_ALWAYS)
|
||
return {'kind': 'any', 'items': items} if len(items) > 1 else items[0]
|
||
|
||
if kind in ('not', 'negate'):
|
||
child = raw.get('item') or raw.get('condition') or raw.get('not')
|
||
if isinstance(child, (list, tuple)) and child:
|
||
child = child[0]
|
||
item = normalize_condition(child, issues, path + '.not', rule_id)
|
||
if item is None:
|
||
return {'kind': 'not', 'item': dict(_ALWAYS)}
|
||
return {'kind': 'not', 'item': item}
|
||
|
||
if kind in ('always', 'true', 'unconditional'):
|
||
return dict(_ALWAYS)
|
||
|
||
if kind == 'expr' or (raw.get('expr') and not raw.get('op') and not raw.get('operator')):
|
||
text = str(raw.get('text') or raw.get('expr') or '').strip()
|
||
if not text:
|
||
return dict(_ALWAYS)
|
||
issues.append(Issue('info', 'I_COND_EXPR', '保留表达式条件: %s' % text, rule_id, path))
|
||
return {'kind': 'expr', 'text': text}
|
||
|
||
# 比较节点
|
||
op_raw = raw.get('op') or raw.get('operator') or raw.get('cmp') or raw.get('compare') or raw.get('rel')
|
||
if op_raw is not None:
|
||
canon = OP_ALIASES.get(str(op_raw).strip().lower())
|
||
if not canon:
|
||
issues.append(Issue('error', 'E_COND_OP_UNKNOWN',
|
||
'未知条件操作符: %r(白名单: %s)' % (op_raw, ', '.join(CANONICAL_OPS)),
|
||
rule_id, path))
|
||
canon = 'eq'
|
||
arity = OP_ARITY.get(canon, 2)
|
||
left_raw = raw.get('left')
|
||
if left_raw is None:
|
||
left_raw = raw.get('field') if raw.get('field') is not None else raw.get('var')
|
||
if left_raw is None:
|
||
left_raw = raw.get('name')
|
||
right_raw = raw.get('right')
|
||
if right_raw is None:
|
||
right_raw = raw.get('value') if raw.get('value') is not None else raw.get('v')
|
||
if right_raw is None:
|
||
right_raw = raw.get('to')
|
||
|
||
if left_raw is None:
|
||
issues.append(Issue('error', 'E_COND_OPERAND', '比较条件缺少左操作数', rule_id, path))
|
||
left_raw = ''
|
||
|
||
node = {'kind': 'compare', 'op': canon,
|
||
'left': normalize_operand(left_raw, issues, path + '.left', rule_id)}
|
||
|
||
if arity >= 2:
|
||
if canon in ('in', 'not_in') and isinstance(right_raw, str):
|
||
right_raw = [x.strip() for x in right_raw.split(',') if x.strip()]
|
||
if canon == 'between':
|
||
pair = right_raw if isinstance(right_raw, (list, tuple)) else \
|
||
[raw.get('from'), raw.get('to')]
|
||
pair = [p for p in pair if p is not None]
|
||
if len(pair) != 2:
|
||
issues.append(Issue('error', 'E_COND_BETWEEN',
|
||
'between 需要 [from, to] 两个边界', rule_id, path))
|
||
pair = (pair + [None, None])[:2]
|
||
node['right'] = {'kind': 'list',
|
||
'items': [normalize_operand(p, issues, path + '.right', rule_id)
|
||
for p in pair]}
|
||
else:
|
||
if right_raw is None:
|
||
issues.append(Issue('error', 'E_COND_OPERAND',
|
||
'操作符 %s 缺少右操作数' % canon, rule_id, path))
|
||
right_raw = ''
|
||
node['right'] = normalize_operand(right_raw, issues, path + '.right', rule_id)
|
||
return node
|
||
|
||
# 隐式:{field, value} / {var, op, value}
|
||
if raw.get('field') or raw.get('var') or raw.get('name'):
|
||
return normalize_condition(
|
||
{'op': raw.get('op') or raw.get('operator') or 'eq',
|
||
'left': raw.get('field') or raw.get('var') or raw.get('name'),
|
||
'right': raw.get('value', raw.get('v'))},
|
||
issues, path, rule_id)
|
||
|
||
issues.append(Issue('warning', 'W_COND_SHAPE',
|
||
'无法识别的条件结构,按 expr 保留', rule_id, path))
|
||
return {'kind': 'expr', 'text': canonical_dumps(strip_volatile(raw))}
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# event 归一
|
||
# ---------------------------------------------------------------------------
|
||
|
||
EVENT_TYPE_ALIASES = {
|
||
'onenter': 'enter', 'on_enter': 'enter', 'sceneenter': 'enter', 'scene_enter': 'enter',
|
||
'onexit': 'exit', 'on_exit': 'exit', 'sceneexit': 'exit', 'scene_exit': 'exit',
|
||
'onclick': 'click', 'on_click': 'click', 'click': 'click', 'tap': 'click',
|
||
'ontimer': 'timer', 'on_timer': 'timer', 'tick': 'timer', 'interval': 'timer',
|
||
'oncollision': 'collision', 'on_collision': 'collision', 'collide': 'collision',
|
||
'onmessage': 'message', 'on_message': 'message', 'msg': 'message',
|
||
'onchange': 'change', 'on_change': 'change', 'valuechange': 'change', 'value_change': 'change',
|
||
'onstart': 'start', 'on_start': 'start', 'gamestart': 'start', 'game_start': 'start',
|
||
'onend': 'end', 'on_end': 'end', 'gameend': 'end', 'game_end': 'end', 'finish': 'end',
|
||
'ontrigger': 'trigger', 'on_trigger': 'trigger', 'custom': 'custom',
|
||
'onsubmit': 'submit', 'on_submit': 'submit', 'submit': 'submit',
|
||
'onanswer': 'answer', 'on_answer': 'answer', 'answer': 'answer',
|
||
'onvaluechange': 'change', 'onvarchange': 'change', 'onvariablechange': 'change',
|
||
'onkeypress': 'keypress', 'on_key_press': 'keypress', 'keypress': 'keypress',
|
||
'oninput': 'input', 'on_input': 'input', 'input': 'input',
|
||
'onwin': 'win', 'on_win': 'win', 'win': 'win', 'onlose': 'lose', 'on_lose': 'lose',
|
||
'lose': 'lose', 'oncomplete': 'complete', 'on_complete': 'complete',
|
||
'complete': 'complete', 'onpick': 'pick', 'on_pick': 'pick', 'pick': 'pick',
|
||
'ondrop': 'drop', 'on_drop': 'drop', 'drop': 'drop',
|
||
}
|
||
|
||
|
||
def normalize_event(raw, issues, path='event', rule_id='', known_types=None):
|
||
"""归一事件触发器。缺失 → type='unknown' + error issue(fail-closed 由调用方决定)。"""
|
||
if raw is None or raw == '' or raw == {} or raw == []:
|
||
issues.append(Issue('error', 'E_EVENT_MISSING', '规则缺少 event 触发器', rule_id, path))
|
||
return {'kind': 'event', 'type': 'unknown', 'source': '', 'match': {}}
|
||
|
||
if isinstance(raw, str):
|
||
raw = {'type': raw}
|
||
elif isinstance(raw, (list, tuple)):
|
||
raw = raw[0] if raw else {}
|
||
issues.append(Issue('warning', 'W_EVENT_MULTI',
|
||
'一个规则只支持单事件触发,已取第一个', rule_id, path))
|
||
if not isinstance(raw, dict):
|
||
issues.append(Issue('error', 'E_EVENT_SHAPE', '事件结构不支持: %r' % type(raw).__name__,
|
||
rule_id, path))
|
||
return {'kind': 'event', 'type': 'unknown', 'source': '', 'match': {}}
|
||
|
||
etype_raw = first_str(raw, ('type', 'event', 'event_type', 'name', 'trigger', 'on'))
|
||
etype = to_snake(etype_raw)
|
||
if etype:
|
||
compact = etype.replace('_', '')
|
||
base = etype[3:] if etype.startswith('on_') else etype
|
||
base_compact = base.replace('_', '')
|
||
etype = (EVENT_TYPE_ALIASES.get(compact)
|
||
or EVENT_TYPE_ALIASES.get(base_compact)
|
||
or base or etype)
|
||
etype = etype or 'unknown'
|
||
if not etype_raw:
|
||
issues.append(Issue('error', 'E_EVENT_MISSING', '事件缺少 type', rule_id, path))
|
||
elif known_types and etype not in known_types \
|
||
and to_snake(etype_raw) not in {to_snake(t) for t in known_types}:
|
||
issues.append(Issue('warning', 'W_EVENT_TYPE_UNKNOWN',
|
||
'事件类型 %s 未在能力/枚举登记表中' % etype, rule_id, path))
|
||
|
||
source = first_str(raw, ('source', 'source_id', 'from', 'emitter', 'target', 'object'))
|
||
# GD 形态 source = 'pbl_mission:12' → 拆 kind / id,保留原串
|
||
source_kind = ''
|
||
source_ref = source
|
||
if source and ':' in source:
|
||
source_kind, _, source_ref = source.partition(':')
|
||
source_kind = to_snake(source_kind)
|
||
|
||
match_raw = raw.get('match') or raw.get('filter') or raw.get('args') or raw.get('payload') or {}
|
||
match = {}
|
||
if isinstance(match_raw, dict):
|
||
for k, v in strip_volatile(match_raw).items():
|
||
ks = to_snake(k)
|
||
if not ks:
|
||
continue
|
||
if isinstance(v, (dict, list, tuple)):
|
||
match[ks] = strip_volatile(v)
|
||
else:
|
||
match[ks] = to_number_or_raw(v)
|
||
elif match_raw not in (None, '', [], {}):
|
||
issues.append(Issue('warning', 'W_EVENT_MATCH_SHAPE',
|
||
'event.match 必须是对象,已忽略', rule_id, path))
|
||
|
||
# timer 事件的周期参数提升到 match,保证运行时可调度
|
||
for key in ('interval_ms', 'interval', 'delay_ms', 'delay', 'period_ms', 'period'):
|
||
if raw.get(key) not in (None, ''):
|
||
match.setdefault('interval_ms', to_int(raw.get(key), 0))
|
||
|
||
ev = {'kind': 'event', 'type': etype, 'source': source, 'match': match}
|
||
if source_kind:
|
||
ev['source_kind'] = source_kind
|
||
ev['source_ref'] = source_ref
|
||
return ev
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# response 归一
|
||
# ---------------------------------------------------------------------------
|
||
|
||
RESPONSE_ALIASES = {
|
||
'then': 'responses', 'response': 'responses', 'actions': 'responses',
|
||
'action': 'responses', 'do': 'responses', 'effects': 'responses', 'effect': 'responses',
|
||
}
|
||
|
||
|
||
def _normalize_one_response(raw, index, issues, path, rule_id, known_actions, strict):
|
||
if raw is None or raw == '':
|
||
return None
|
||
if isinstance(raw, str):
|
||
raw = {'action': raw}
|
||
if not isinstance(raw, dict):
|
||
issues.append(Issue('error', 'E_RESP_SHAPE',
|
||
'响应必须是对象或字符串,得到 %s' % type(raw).__name__, rule_id, path))
|
||
return None
|
||
|
||
action_raw = first_str(raw, ('action', 'type', 'kind', 'name', 'op', 'command', 'do'))
|
||
action = to_snake(action_raw)
|
||
if not action:
|
||
issues.append(Issue('error', 'E_RESP_ACTION_MISSING', '响应缺少 action', rule_id, path))
|
||
return None
|
||
|
||
if known_actions is not None and action not in known_actions:
|
||
sev = 'error' if strict else 'warning'
|
||
issues.append(Issue(sev, 'E_CAP_UNKNOWN' if strict else 'W_CAP_UNKNOWN',
|
||
'响应 action `%s` 未在 pbl_capability_registry 登记'
|
||
% action, rule_id, path))
|
||
if strict:
|
||
return None
|
||
|
||
target_raw = raw.get('target')
|
||
if target_raw is None:
|
||
target_raw = raw.get('to') if raw.get('to') is not None else raw.get('object')
|
||
if isinstance(target_raw, dict):
|
||
target = {'kind': str(target_raw.get('kind') or target_raw.get('type') or 'entity'),
|
||
'id': first_str(target_raw, ('id', 'entity_id', 'name', 'value'))}
|
||
elif isinstance(target_raw, (list, tuple)):
|
||
target = {'kind': 'list', 'id': '',
|
||
'ids': sorted({str(x) for x in target_raw if str(x)})}
|
||
elif target_raw in (None, ''):
|
||
target = {'kind': 'none', 'id': ''}
|
||
else:
|
||
s = str(target_raw).strip()
|
||
if s.lower() in ('self', 'this', 'trigger', 'source'):
|
||
target = {'kind': 'self', 'id': ''}
|
||
elif s.lower() in ('all', 'everyone', '*'):
|
||
target = {'kind': 'all', 'id': ''}
|
||
else:
|
||
target = {'kind': 'entity', 'id': s}
|
||
|
||
params_raw = raw.get('params')
|
||
if params_raw is None:
|
||
params_raw = raw.get('args') or raw.get('payload') or raw.get('data')
|
||
if params_raw is None:
|
||
# GD 形态:response 项把业务字段平铺({'op':'emit','event':'x','capability':'pbl.y'})
|
||
_RESERVED = {'action', 'type', 'kind', 'name', 'op', 'command', 'do',
|
||
'target', 'to', 'object', 'params', 'args', 'payload', 'data',
|
||
'delay_ms', 'delay', 'repeat', 'times', 'condition', 'when', 'if'}
|
||
flat = {k: v for k, v in raw.items() if str(k).strip().lower() not in _RESERVED}
|
||
params_raw = strip_volatile(flat)
|
||
if not isinstance(params_raw, dict):
|
||
if params_raw in (None, '', [], {}):
|
||
params_raw = {}
|
||
else:
|
||
params_raw = {'value': to_number_or_raw(params_raw)}
|
||
params = {}
|
||
for k, v in strip_volatile(params_raw).items():
|
||
ks = to_snake(k)
|
||
if not ks:
|
||
continue
|
||
params[ks] = _normalize_param_value(v)
|
||
|
||
# 常见同义参数键归一
|
||
for a, b in (('val', 'value'), ('amount', 'value'), ('delta', 'value'),
|
||
('text', 'message'), ('msg', 'message'), ('scene', 'scene_id'),
|
||
('next_scene', 'scene_id'), ('to_scene', 'scene_id')):
|
||
if a in params and b not in params:
|
||
params[b] = params.pop(a)
|
||
|
||
out = {
|
||
'kind': 'action',
|
||
'action': action,
|
||
'target': target,
|
||
'params': params,
|
||
'delay_ms': max(0, to_int(raw.get('delay_ms', raw.get('delay', 0)), 0)),
|
||
'repeat': max(0, to_int(raw.get('repeat', raw.get('times', 1)), 1)),
|
||
}
|
||
cap = first_str(raw, ('capability', 'capability_key', 'cap')) or \
|
||
first_str(params, ('capability', 'capability_key'))
|
||
if cap:
|
||
out['capability'] = cap
|
||
params.pop('capability', None)
|
||
params.pop('capability_key', None)
|
||
ev = first_str(raw, ('event', 'emit', 'emit_event'))
|
||
if ev and action in ('emit', 'emit_event', 'fire'):
|
||
params.setdefault('event', to_snake(ev) or ev)
|
||
if raw.get('condition') not in (None, '', [], {}):
|
||
sub = normalize_condition(raw.get('condition'), issues, path + '.condition', rule_id)
|
||
if sub is not None:
|
||
out['condition'] = sub
|
||
return out
|
||
|
||
|
||
def _normalize_param_value(v, depth=0):
|
||
if depth > 16:
|
||
return None
|
||
if isinstance(v, dict):
|
||
return {to_snake(k) or str(k): _normalize_param_value(x, depth + 1)
|
||
for k, x in strip_volatile(v).items()}
|
||
if isinstance(v, (list, tuple)):
|
||
return [_normalize_param_value(x, depth + 1) for x in v]
|
||
return to_number_or_raw(v)
|
||
|
||
|
||
def normalize_responses(raw, issues, path='responses', rule_id='',
|
||
known_actions=None, strict=True):
|
||
if raw is None or raw == '' or raw == {} or raw == []:
|
||
issues.append(Issue('error', 'E_RESP_MISSING', '规则缺少 response(至少一个动作)',
|
||
rule_id, path))
|
||
return []
|
||
if isinstance(raw, dict):
|
||
# {"responses": [...]} 嵌套 或 单个动作对象
|
||
inner = None
|
||
for k, v in raw.items():
|
||
if str(k).strip().lower() in RESPONSE_ALIASES and isinstance(v, (list, tuple, dict)):
|
||
inner = v
|
||
break
|
||
raw = inner if inner is not None else [raw]
|
||
if not isinstance(raw, (list, tuple)):
|
||
raw = [raw]
|
||
out = []
|
||
for i, item in enumerate(raw):
|
||
r = _normalize_one_response(item, i, issues, '%s[%d]' % (path, i),
|
||
rule_id, known_actions, strict)
|
||
if r is not None:
|
||
out.append(r)
|
||
if not out:
|
||
issues.append(Issue('error', 'E_RESP_EMPTY',
|
||
'规则响应归一后为空(strict 模式下未登记 action 会被剔除)',
|
||
rule_id, path))
|
||
return out
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# ECR 源枚举(兼容蓝图快照的多种形态)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_ECR_LIST_KEYS = ('ecr', 'ecrs', 'rules', 'rule', 'event_rules', 'logic', 'behaviors',
|
||
'triggers', 'event_conditions', 'scripts')
|
||
_EVENT_LIST_KEYS = ('events', 'event', 'scene_events')
|
||
|
||
|
||
def iter_ecr_sources(snapshot):
|
||
"""从蓝图版本快照里确定性地枚举 ECR 原始记录。
|
||
|
||
支持形态:
|
||
A. snapshot['rules' | 'ecr' | 'logic' | ...] = [ {event, condition, responses}, ... ]
|
||
B. snapshot['events'] = [ {id, type, conditions, responses}, ... ] (事件内嵌条件/响应)
|
||
C. snapshot['scenes'] = [ {events: [...] } ] (场景内嵌事件)
|
||
D. snapshot['children' | 'sub_objects' | 'objects'] = [ {kind:'rule'|'event', ...} ]
|
||
|
||
返回 [(source_path, raw_dict), ...],按 source_path 排序保证确定性。
|
||
"""
|
||
found = []
|
||
if not isinstance(snapshot, dict):
|
||
return found
|
||
|
||
def _walk(node, prefix, depth=0):
|
||
if depth > 8 or not isinstance(node, dict):
|
||
return
|
||
for key in _ECR_LIST_KEYS:
|
||
val = node.get(key)
|
||
if isinstance(val, dict):
|
||
val = [val]
|
||
if isinstance(val, (list, tuple)):
|
||
for i, item in enumerate(val):
|
||
if isinstance(item, dict) and _looks_like_ecr(item):
|
||
found.append(('%s.%s[%d]' % (prefix, key, i), item))
|
||
for key in _EVENT_LIST_KEYS:
|
||
val = node.get(key)
|
||
if isinstance(val, dict):
|
||
val = [val]
|
||
if isinstance(val, (list, tuple)):
|
||
for i, item in enumerate(val):
|
||
if isinstance(item, dict) and _looks_like_ecr(item):
|
||
found.append(('%s.%s[%d]' % (prefix, key, i), item))
|
||
for key in ('scenes', 'scene', 'children', 'sub_objects', 'objects', 'nodes', 'items'):
|
||
val = node.get(key)
|
||
if isinstance(val, dict):
|
||
val = [val]
|
||
if isinstance(val, (list, tuple)):
|
||
for i, item in enumerate(val):
|
||
if isinstance(item, dict):
|
||
_walk(item, '%s.%s[%d]' % (prefix, key, i), depth + 1)
|
||
|
||
_walk(snapshot, 'snapshot')
|
||
|
||
# 去重(同一对象可能同时出现在 rules 与 events 下):按内容哈希保留首个路径
|
||
seen = {}
|
||
uniq = []
|
||
for path, raw in sorted(found, key=lambda x: x[0]):
|
||
h = sha256_text(canonical_dumps(strip_volatile(raw)))
|
||
if h in seen:
|
||
continue
|
||
seen[h] = path
|
||
uniq.append((path, raw))
|
||
return uniq
|
||
|
||
|
||
_TRIGGER_KEYS = {'event', 'trigger', 'on', 'type', 'event_type'}
|
||
_EFFECT_KEYS = {'responses', 'response', 'actions', 'action', 'then', 'do', 'effects', 'effect'}
|
||
_COND_KEYS = {'condition', 'conditions', 'when', 'if'}
|
||
# 容器 / 实体标记键:命中且无触发器时不视为 ECR(避免把 scene/entity 误当规则)
|
||
_CONTAINER_KEYS = {'scenes', 'scene', 'children', 'sub_objects', 'objects', 'nodes',
|
||
'events', 'rules', 'items', 'entities', 'components', 'assets',
|
||
'position', 'transform', 'mesh', 'variables', 'ui'}
|
||
_ECR_KINDS = {'rule', 'ecr', 'event_rule', 'logic', 'behavior', 'trigger', 'event'}
|
||
|
||
|
||
def _looks_like_ecr(d):
|
||
keys = {str(k).strip().lower() for k in d.keys()}
|
||
has_trigger = bool(keys & _TRIGGER_KEYS)
|
||
has_effect = bool(keys & _EFFECT_KEYS)
|
||
has_cond = bool(keys & _COND_KEYS)
|
||
kind = str(d.get('kind') or d.get('object_type') or d.get('sub_type') or '').lower()
|
||
if kind in _ECR_KINDS:
|
||
return True
|
||
score = sum([has_trigger, has_effect, has_cond])
|
||
if score == 0:
|
||
return False
|
||
if (keys & _CONTAINER_KEYS) and not has_trigger:
|
||
return False
|
||
# 只有响应没有触发器:需带 id/name 标识才当规则(畸形规则要报错,不能静默丢)
|
||
if not has_trigger and not has_cond:
|
||
return bool(keys & {'id', 'name', 'rule_id', 'key', 'code'})
|
||
return True
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 单条 ECR → 规则
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _derive_rule_id(rule_core):
|
||
return 'rule_' + sha256_text(canonical_dumps(rule_core))[:16]
|
||
|
||
|
||
def normalize_ecr(raw, index, ctx=None, source_path=''):
|
||
"""把一条原始 ECR 记录归一为 script_type=1 规则 JSON。返回 (rule, issues)。"""
|
||
ctx = ctx or {}
|
||
issues = []
|
||
raw = strip_volatile(raw) if isinstance(raw, dict) else {}
|
||
|
||
source_id = first_str(raw, ('id', 'rule_id', 'event_key', 'key', 'code',
|
||
'object_id', 'sub_object_id'))
|
||
name = first_str(raw, ('name', 'title', 'label', 'rule_name')) or \
|
||
'rule_%03d' % (index + 1)
|
||
description = first_str(raw, ('description', 'desc', 'remark', 'memo'))
|
||
priority = to_int(raw.get('priority', raw.get('order', raw.get('weight', 100))), 100)
|
||
enabled = to_bool(raw.get('enabled', raw.get('active', raw.get('is_active', True))), True)
|
||
|
||
rule_id_hint = source_id
|
||
tmp_id = rule_id_hint or ('pending_%d' % index)
|
||
|
||
event = normalize_event(
|
||
raw.get('event') if raw.get('event') is not None else
|
||
(raw.get('trigger') if raw.get('trigger') is not None else raw.get('on')),
|
||
issues, 'event', tmp_id, ctx.get('event_types'))
|
||
|
||
# 形态 B:事件对象自身即触发器({id,type,conditions,responses})
|
||
if event.get('type') == 'unknown' and (raw.get('type') or raw.get('event_type')):
|
||
event = normalize_event({'type': raw.get('type') or raw.get('event_type'),
|
||
'source': raw.get('source') or raw.get('source_id'),
|
||
'match': raw.get('match') or raw.get('args') or {}},
|
||
issues, 'event', tmp_id, ctx.get('event_types'))
|
||
issues[:] = [i for i in issues if i['code'] != 'E_EVENT_MISSING']
|
||
|
||
cond_raw = raw.get('condition')
|
||
if cond_raw is None:
|
||
cond_raw = raw.get('conditions')
|
||
if cond_raw is None:
|
||
cond_raw = raw.get('when')
|
||
if cond_raw is None:
|
||
cond_raw = raw.get('if')
|
||
condition = normalize_condition(cond_raw, issues, 'condition', tmp_id)
|
||
if condition is None:
|
||
condition = dict(_ALWAYS)
|
||
|
||
resp_raw = raw.get('responses')
|
||
for k in ('response', 'actions', 'action', 'then', 'do', 'effects'):
|
||
if resp_raw is None:
|
||
resp_raw = raw.get(k)
|
||
responses = normalize_responses(resp_raw, issues, 'responses', tmp_id,
|
||
ctx.get('known_actions'),
|
||
bool(ctx.get('strict', True)))
|
||
|
||
limits = raw.get('limits') if isinstance(raw.get('limits'), dict) else {}
|
||
limits_out = {
|
||
'cooldown_ms': max(0, to_int(
|
||
limits.get('cooldown_ms', raw.get('cooldown_ms', raw.get('cooldown', 0))), 0)),
|
||
'max_triggers': max(0, to_int(
|
||
limits.get('max_triggers', raw.get('max_triggers', raw.get('max_times', 0))), 0)),
|
||
}
|
||
|
||
tags_raw = raw.get('tags') or raw.get('tag') or raw.get('labels') or []
|
||
if isinstance(tags_raw, str):
|
||
tags_raw = [t for t in re.split(r'[,;|\s]+', tags_raw) if t]
|
||
tags = sorted({to_snake(t) or str(t).strip() for t in tags_raw if str(t).strip()})
|
||
|
||
origin = {
|
||
'blueprint_id': str(ctx.get('blueprint_id') or ''),
|
||
'blueprint_version': str(ctx.get('blueprint_version') or ''),
|
||
'sub_object_id': source_id,
|
||
'source_index': int(index),
|
||
'source_path': source_path or '',
|
||
}
|
||
|
||
core = {
|
||
'schema_version': RULE_SCHEMA_VERSION,
|
||
'name': name,
|
||
'description': description,
|
||
'priority': priority,
|
||
'enabled': bool(enabled),
|
||
'event': event,
|
||
'condition': condition,
|
||
'responses': responses,
|
||
'limits': limits_out,
|
||
'tags': tags,
|
||
'origin': origin,
|
||
}
|
||
|
||
rule_id = source_id if source_id else _derive_rule_id(core)
|
||
rule = dict(core)
|
||
rule['rule_id'] = rule_id
|
||
rule['source_id'] = source_id
|
||
# 键序无关(canonical_dumps sort_keys),但保持可读顺序
|
||
ordered = {
|
||
'schema_version': rule['schema_version'],
|
||
'rule_id': rule['rule_id'],
|
||
'source_id': rule['source_id'],
|
||
'name': rule['name'],
|
||
'description': rule['description'],
|
||
'priority': rule['priority'],
|
||
'enabled': rule['enabled'],
|
||
'event': rule['event'],
|
||
'condition': rule['condition'],
|
||
'responses': rule['responses'],
|
||
'limits': rule['limits'],
|
||
'tags': rule['tags'],
|
||
'origin': rule['origin'],
|
||
}
|
||
for i in issues:
|
||
i['rule_id'] = rule_id
|
||
return ordered, issues
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 批量编译
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def sort_rules(rules):
|
||
"""确定性排序:priority 降序 → rule_id 升序。"""
|
||
return sorted(rules, key=lambda r: (-to_int(r.get('priority'), 0), str(r.get('rule_id'))))
|
||
|
||
|
||
def build_rules(snapshot, ctx=None):
|
||
"""蓝图版本快照 → (rules, issues)。
|
||
|
||
ctx: {tenant_id, blueprint_id, blueprint_version, known_actions(set|None),
|
||
event_types(set|None), strict(bool, 默认 True)}
|
||
"""
|
||
ctx = dict(ctx or {})
|
||
ctx.setdefault('strict', True)
|
||
issues = []
|
||
|
||
tenant_id = str(ctx.get('tenant_id') or '').strip()
|
||
if not tenant_id:
|
||
raise CompileError('E_TENANT_REQUIRED', 'tenant_id 缺失,拒绝编译(多租户铁律)')
|
||
|
||
if not isinstance(snapshot, dict) or not snapshot:
|
||
raise CompileError('E_SNAPSHOT_EMPTY', '蓝图版本快照为空,无法映射规则')
|
||
|
||
sources = iter_ecr_sources(snapshot)
|
||
if not sources:
|
||
issues.append(Issue('warning', 'W_NO_ECR', '快照中未发现 event→condition→response 记录'))
|
||
|
||
rules = []
|
||
seen_ids = {}
|
||
dropped = 0
|
||
for idx, (path, raw) in enumerate(sources):
|
||
rule, ri = normalize_ecr(raw, idx, ctx, path)
|
||
issues.extend(ri)
|
||
# fail-closed:触发器无效或响应为空的规则不进入产物(错误已记 issue,编译整体失败)
|
||
if not rule['responses'] or (rule['event'] or {}).get('type') in (None, '', 'unknown'):
|
||
dropped += 1
|
||
issues.append(Issue('error', 'E_RULE_DROPPED',
|
||
'规则被剔除(event 无效或 responses 为空),不产出半成品: %s'
|
||
% (rule.get('source_id') or rule.get('name') or path),
|
||
rule['rule_id'], path))
|
||
continue
|
||
rid = rule['rule_id']
|
||
if rid in seen_ids:
|
||
issues.append(Issue('error', 'E_RULE_ID_DUP',
|
||
'rule_id 重复: %s(首次出现 %s,本次 %s)'
|
||
% (rid, seen_ids[rid], path), rid, path))
|
||
rule['rule_id'] = rid + '_' + sha256_text(path)[:6]
|
||
rid = rule['rule_id']
|
||
seen_ids[rid] = path
|
||
rules.append(rule)
|
||
|
||
rules = sort_rules(rules)
|
||
# origin.source_index 保留原始枚举序,确保可追溯;dropped 计数供上层告警
|
||
if dropped:
|
||
issues.append(Issue('error', 'E_RULES_DROPPED_TOTAL',
|
||
'共剔除 %d 条无效规则' % dropped))
|
||
return rules, issues
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 校验
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_REQUIRED_RULE_KEYS = ('schema_version', 'rule_id', 'name', 'priority', 'enabled',
|
||
'event', 'condition', 'responses', 'limits', 'tags', 'origin')
|
||
|
||
|
||
def validate_rule(rule):
|
||
"""校验单条已归一规则,返回 issues(不修改入参)。"""
|
||
issues = []
|
||
if not isinstance(rule, dict):
|
||
return [Issue('error', 'E_RULE_SHAPE', '规则必须是对象')]
|
||
rid = str(rule.get('rule_id') or '')
|
||
for k in _REQUIRED_RULE_KEYS:
|
||
if k not in rule:
|
||
issues.append(Issue('error', 'E_RULE_KEY_MISSING', '规则缺少字段 %s' % k, rid))
|
||
if not rid:
|
||
issues.append(Issue('error', 'E_RULE_ID_MISSING', '规则缺少 rule_id'))
|
||
if str(rule.get('schema_version') or '') != RULE_SCHEMA_VERSION:
|
||
issues.append(Issue('error', 'E_RULE_SCHEMA',
|
||
'schema_version 必须为 %s' % RULE_SCHEMA_VERSION, rid))
|
||
ev = rule.get('event') or {}
|
||
if not isinstance(ev, dict) or not ev.get('type') or ev.get('type') == 'unknown':
|
||
issues.append(Issue('error', 'E_EVENT_MISSING', '规则 event.type 无效', rid, 'event'))
|
||
resp = rule.get('responses')
|
||
if not isinstance(resp, list) or not resp:
|
||
issues.append(Issue('error', 'E_RESP_EMPTY', '规则至少需要一个 response', rid, 'responses'))
|
||
else:
|
||
for i, r in enumerate(resp):
|
||
if not isinstance(r, dict) or not r.get('action'):
|
||
issues.append(Issue('error', 'E_RESP_ACTION_MISSING',
|
||
'response[%d] 缺少 action' % i, rid, 'responses[%d]' % i))
|
||
elif not re.fullmatch(r'[a-z][a-z0-9_]{0,63}', str(r.get('action'))):
|
||
issues.append(Issue('error', 'E_RESP_ACTION_FORMAT',
|
||
'action 必须是 lower_snake(≤64字符): %r' % r.get('action'),
|
||
rid, 'responses[%d]' % i))
|
||
_validate_condition(rule.get('condition'), issues, rid, 'condition')
|
||
return issues
|
||
|
||
|
||
def _validate_condition(node, issues, rid, path, depth=0):
|
||
if depth > 24:
|
||
issues.append(Issue('error', 'E_COND_DEPTH', '条件嵌套过深(>24)', rid, path))
|
||
return
|
||
if node is None:
|
||
issues.append(Issue('error', 'E_COND_MISSING', '条件为空', rid, path))
|
||
return
|
||
if not isinstance(node, dict):
|
||
issues.append(Issue('error', 'E_COND_SHAPE', '条件必须是对象', rid, path))
|
||
return
|
||
kind = node.get('kind')
|
||
if kind == 'always':
|
||
return
|
||
if kind in ('all', 'any'):
|
||
items = node.get('items')
|
||
if not isinstance(items, list) or not items:
|
||
issues.append(Issue('error', 'E_COND_ITEMS', '%s 需要非空 items' % kind, rid, path))
|
||
return
|
||
for i, it in enumerate(items):
|
||
_validate_condition(it, issues, rid, '%s.%s[%d]' % (path, kind, i), depth + 1)
|
||
return
|
||
if kind == 'not':
|
||
_validate_condition(node.get('item'), issues, rid, path + '.not', depth + 1)
|
||
return
|
||
if kind == 'expr':
|
||
if not str(node.get('text') or '').strip():
|
||
issues.append(Issue('error', 'E_COND_EXPR_EMPTY', 'expr 条件缺少 text', rid, path))
|
||
return
|
||
if kind == 'compare':
|
||
op = node.get('op')
|
||
if op not in OP_ARITY:
|
||
issues.append(Issue('error', 'E_COND_OP_UNKNOWN',
|
||
'未知操作符 %r' % op, rid, path))
|
||
return
|
||
arity = OP_ARITY[op]
|
||
left = node.get('left')
|
||
if not isinstance(left, dict) or not left.get('kind'):
|
||
issues.append(Issue('error', 'E_COND_OPERAND', '左操作数无效', rid, path + '.left'))
|
||
elif left.get('kind') == 'var' and not left.get('name'):
|
||
issues.append(Issue('error', 'E_COND_OPERAND', '变量操作数 name 为空', rid, path + '.left'))
|
||
if arity >= 2:
|
||
right = node.get('right')
|
||
if not isinstance(right, dict) or not right.get('kind'):
|
||
issues.append(Issue('error', 'E_COND_OPERAND', '右操作数无效', rid, path + '.right'))
|
||
elif op == 'between':
|
||
items = right.get('items') or []
|
||
if len(items) != 2 or any(i.get('value') is None for i in items
|
||
if isinstance(i, dict)):
|
||
issues.append(Issue('error', 'E_COND_BETWEEN',
|
||
'between 需要两个非空边界', rid, path + '.right'))
|
||
return
|
||
issues.append(Issue('error', 'E_COND_KIND', '未知条件 kind: %r' % kind, rid, path))
|
||
|
||
|
||
def validate_rules(rules):
|
||
issues = []
|
||
ids = set()
|
||
for r in rules or []:
|
||
rid = str((r or {}).get('rule_id') or '')
|
||
if rid and rid in ids:
|
||
issues.append(Issue('error', 'E_RULE_ID_DUP', 'rule_id 重复: %s' % rid, rid))
|
||
ids.add(rid)
|
||
issues.extend(validate_rule(r))
|
||
return issues
|
||
|
||
|
||
def has_errors(issues):
|
||
return any(i.get('severity') == 'error' for i in issues or [])
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 产物序列化
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def canonical_rules_json(rules):
|
||
"""规则数组 → 确定性 JSON 文本(script_engine script_type=1 直接可加载)。"""
|
||
return canonical_dumps(sort_rules(rules or []))
|
||
|
||
|
||
def rule_content_json(rule):
|
||
"""单条规则 → 确定性 JSON 文本(写入 script 表 content 字段)。"""
|
||
return canonical_dumps(rule)
|
||
|
||
|
||
def rules_hash(rules):
|
||
return sha256_text(canonical_rules_json(rules))
|
||
|
||
|
||
def count_conditions(node):
|
||
if not isinstance(node, dict):
|
||
return 0
|
||
kind = node.get('kind')
|
||
if kind in ('all', 'any'):
|
||
return 1 + sum(count_conditions(i) for i in (node.get('items') or []))
|
||
if kind == 'not':
|
||
return 1 + count_conditions(node.get('item'))
|
||
return 1
|
||
|
||
|
||
def build_rules_manifest(rules, extra=None):
|
||
"""规则清单(供版本比对 / 导出校验 / GD 引用)。确定性:不含时间戳。"""
|
||
rules = sort_rules(rules or [])
|
||
entries = []
|
||
for r in rules:
|
||
entries.append({
|
||
'rule_id': str(r.get('rule_id') or ''),
|
||
'name': str(r.get('name') or ''),
|
||
'priority': to_int(r.get('priority'), 0),
|
||
'enabled': bool(r.get('enabled')),
|
||
'event_type': str((r.get('event') or {}).get('type') or ''),
|
||
'condition_count': count_conditions(r.get('condition')),
|
||
'response_count': len(r.get('responses') or []),
|
||
'content_hash': sha256_text(canonical_dumps(r)),
|
||
})
|
||
manifest = {
|
||
'schema_version': RULE_SCHEMA_VERSION,
|
||
'script_type': 1,
|
||
'rule_count': len(entries),
|
||
'enabled_count': sum(1 for e in entries if e['enabled']),
|
||
'disabled_count': sum(1 for e in entries if not e['enabled']),
|
||
'response_total': sum(e['response_count'] for e in entries),
|
||
'condition_total': sum(e['condition_count'] for e in entries),
|
||
'rules_hash': rules_hash(rules),
|
||
'rules': entries,
|
||
}
|
||
if extra:
|
||
manifest['extra'] = strip_volatile(extra)
|
||
return manifest
|
||
|
||
|
||
def diff_rules(old_rules, new_rules):
|
||
"""版本比对:added / removed / changed(按 content_hash)。确定性输出。"""
|
||
def _idx(rs):
|
||
out = {}
|
||
for r in rs or []:
|
||
rid = str((r or {}).get('rule_id') or '')
|
||
if rid:
|
||
out[rid] = sha256_text(canonical_dumps(r))
|
||
return out
|
||
|
||
o, n = _idx(old_rules), _idx(new_rules)
|
||
added = sorted(k for k in n if k not in o)
|
||
removed = sorted(k for k in o if k not in n)
|
||
changed = sorted(k for k in n if k in o and o[k] != n[k])
|
||
return {
|
||
'added': added, 'removed': removed, 'changed': changed,
|
||
'unchanged_count': len([k for k in n if k in o and o[k] == n[k]]),
|
||
'added_count': len(added), 'removed_count': len(removed),
|
||
'changed_count': len(changed),
|
||
'old_rules_hash': rules_hash(old_rules or []),
|
||
'new_rules_hash': rules_hash(new_rules or []),
|
||
}
|