had
This commit is contained in:
@@ -17,11 +17,14 @@ __all__ = [
|
||||
"_deep_find_text",
|
||||
"_best_text_from_outputs",
|
||||
"render_template_simple",
|
||||
"eval_condition_expr",
|
||||
]
|
||||
|
||||
# Regex-макросы (общие для бэка)
|
||||
_OUT_MACRO_RE = re.compile(r"\[\[\s*OUT\s*[:\s]\s*([^\]]+?)\s*\]\]", re.IGNORECASE)
|
||||
_VAR_MACRO_RE = re.compile(r"\[\[\s*VAR\s*[:\s]\s*([^\]]+?)\s*\]\]", re.IGNORECASE)
|
||||
# STORE: постоянное хранилище переменных (пер-пайплайн)
|
||||
_STORE_MACRO_RE = re.compile(r"\[\[\s*STORE\s*[:\s]\s*([^\]]+?)\s*\]\]", re.IGNORECASE)
|
||||
# Единый фрагмент PROMPT (провайдеро-специфичный JSON-фрагмент)
|
||||
_PROMPT_MACRO_RE = re.compile(r"\[\[\s*PROMPT\s*\]\]", re.IGNORECASE)
|
||||
# Короткая форма: [[OUT1]] — best-effort текст из ноды n1
|
||||
@@ -29,7 +32,8 @@ _OUT_SHORT_RE = re.compile(r"\[\[\s*OUT\s*(\d+)\s*\]\]", re.IGNORECASE)
|
||||
# Голые переменные: [[NAME]] или [[path.to.value]] — сначала ищем в vars, затем в контексте
|
||||
_BARE_MACRO_RE = re.compile(r"\[\[\s*([A-Za-z_][A-Za-z0-9_]*(?:\.[^\]]+?)?)\s*\]\]")
|
||||
# Подстановки {{ ... }} (включая простейший фильтр |default(...))
|
||||
_BRACES_RE = re.compile(r"\{\{\s*([^}]+?)\s*\}\}")
|
||||
# Разбираем выражение до ближайшего '}}', допускаем '}' внутри (например в JSON-литералах)
|
||||
_BRACES_RE = re.compile(r"\{\{\s*(.*?)\s*\}\}", re.DOTALL)
|
||||
|
||||
|
||||
def _split_path(path: str) -> List[str]:
|
||||
@@ -192,13 +196,14 @@ def render_template_simple(template: str, context: Dict[str, Any], out_map: Dict
|
||||
value может быть числом, строкой ('..'/".."), массивом/объектом в виде литерала.
|
||||
- [[VAR:path]] — берёт из context
|
||||
- [[OUT:nodeId(.path)*]] — берёт из out_map
|
||||
- [[STORE:path]] — берёт из постоянного хранилища (context.store)
|
||||
Возвращает строку.
|
||||
"""
|
||||
if template is None:
|
||||
return ""
|
||||
s = str(template)
|
||||
|
||||
# 1) Макросы [[VAR:...]] и [[OUT:...]]
|
||||
# 1) Макросы [[VAR:...]] / [[OUT:...]] / [[STORE:...]]
|
||||
def repl_var(m: re.Match) -> str:
|
||||
path = m.group(1).strip()
|
||||
val = _get_by_path(context, path)
|
||||
@@ -214,8 +219,15 @@ def render_template_simple(template: str, context: Dict[str, Any], out_map: Dict
|
||||
val = out_map.get(body)
|
||||
return _stringify_for_template(val)
|
||||
|
||||
def repl_store(m: re.Match) -> str:
|
||||
path = m.group(1).strip()
|
||||
store = context.get("store") or {}
|
||||
val = _get_by_path(store, path)
|
||||
return _stringify_for_template(val)
|
||||
|
||||
s = _VAR_MACRO_RE.sub(repl_var, s)
|
||||
s = _OUT_MACRO_RE.sub(repl_out, s)
|
||||
s = _STORE_MACRO_RE.sub(repl_store, s)
|
||||
|
||||
# [[OUT1]] → текст из ноды n1 (best-effort)
|
||||
def repl_out_short(m: re.Match) -> str:
|
||||
@@ -250,7 +262,7 @@ def render_template_simple(template: str, context: Dict[str, Any], out_map: Dict
|
||||
# 2) Подстановки {{ ... }} (+ simple default filter)
|
||||
def repl_braces(m: re.Match) -> str:
|
||||
expr = m.group(1).strip()
|
||||
|
||||
|
||||
def eval_path(p: str) -> Any:
|
||||
p = p.strip()
|
||||
# Приоритет пользовательских переменных для простых идентификаторов {{ NAME }}
|
||||
@@ -264,8 +276,13 @@ def render_template_simple(template: str, context: Dict[str, Any], out_map: Dict
|
||||
node_val = out_map.get(node_id)
|
||||
return _get_by_path(node_val, rest)
|
||||
return out_map.get(body)
|
||||
# STORE.* — из постоянного хранилища
|
||||
if p.startswith("STORE.") or p.startswith("store."):
|
||||
body = p.split(".", 1)[1] if "." in p else ""
|
||||
store = context.get("store") or {}
|
||||
return _get_by_path(store, body)
|
||||
return _get_by_path(context, p)
|
||||
|
||||
|
||||
default_match = re.match(r"([^|]+)\|\s*default\((.*)\)\s*$", expr)
|
||||
if default_match:
|
||||
base_path = default_match.group(1).strip()
|
||||
@@ -305,4 +322,413 @@ def render_template_simple(template: str, context: Dict[str, Any], out_map: Dict
|
||||
return _stringify_for_template(val)
|
||||
|
||||
s = _BRACES_RE.sub(repl_braces, s)
|
||||
return s
|
||||
return s
|
||||
|
||||
|
||||
# --- Boolean condition evaluator for If-node ---------------------------------
|
||||
# Поддерживает:
|
||||
# - Операторы: &&, ||, !, ==, !=, <, <=, >, >=, contains
|
||||
# - Скобки (...)
|
||||
# - Токены-литералы: числа (int/float), строки "..." (без escape-сложностей)
|
||||
# - Макросы: [[VAR:...]], [[OUT:...]], [[OUT1]], [[NAME]] (vars/context),
|
||||
# {{ path }} и {{ path|default(...) }} — типобезопасно (числа остаются числами)
|
||||
# Возвращает bool. Бросает ValueError при синтаксической/семантической ошибке.
|
||||
def eval_condition_expr(expr: str, context: Dict[str, Any], out_map: Dict[str, Any]) -> bool:
|
||||
import ast
|
||||
|
||||
if expr is None:
|
||||
return False
|
||||
s = str(expr)
|
||||
|
||||
# Tokenize into a flat list of tokens and build value bindings for macros/braces.
|
||||
tokens, bindings = _tokenize_condition_expr(s, context, out_map)
|
||||
|
||||
# Transform infix "contains" into function form contains(a,b)
|
||||
tokens = _transform_contains(tokens)
|
||||
|
||||
# Join into python-like boolean expression and map logical ops.
|
||||
py_expr = _tokens_to_python_expr(tokens)
|
||||
|
||||
# Evaluate safely via AST with strict whitelist
|
||||
result = _safe_eval_bool(py_expr, bindings)
|
||||
return bool(result)
|
||||
|
||||
|
||||
def _tokens_to_python_expr(tokens: List[str]) -> str:
|
||||
# Уже нормализовано на этапе токенизации, просто склеиваем с пробелами
|
||||
return " ".join(tokens)
|
||||
|
||||
|
||||
def _transform_contains(tokens: List[str]) -> List[str]:
|
||||
# Заменяет "... A contains B ..." на "contains(A, B)" с учётом скобок.
|
||||
i = 0
|
||||
out: List[str] = tokens[:] # копия
|
||||
# Итерируем, пока встречается 'contains'
|
||||
while True:
|
||||
try:
|
||||
idx = out.index("contains")
|
||||
except ValueError:
|
||||
break
|
||||
|
||||
# Левая часть
|
||||
lstart = idx - 1
|
||||
if lstart >= 0 and out[lstart] == ")":
|
||||
# найти соответствующую открывающую "("
|
||||
bal = 0
|
||||
j = lstart
|
||||
while j >= 0:
|
||||
if out[j] == ")":
|
||||
bal += 1
|
||||
elif out[j] == "(":
|
||||
bal -= 1
|
||||
if bal == 0:
|
||||
lstart = j
|
||||
break
|
||||
j -= 1
|
||||
if bal != 0:
|
||||
# несбалансированные скобки
|
||||
raise ValueError("Unbalanced parentheses around left operand of contains")
|
||||
# Правая часть
|
||||
rend = idx + 1
|
||||
if rend < len(out) and out[rend] == "(":
|
||||
bal = 0
|
||||
j = rend
|
||||
while j < len(out):
|
||||
if out[j] == "(":
|
||||
bal += 1
|
||||
elif out[j] == ")":
|
||||
bal -= 1
|
||||
if bal == 0:
|
||||
rend = j
|
||||
break
|
||||
j += 1
|
||||
if bal != 0:
|
||||
raise ValueError("Unbalanced parentheses around right operand of contains")
|
||||
# Если нет скобок — однотокенный операнд
|
||||
left_tokens = out[lstart:idx]
|
||||
right_tokens = out[idx + 1:rend + 1] if (idx + 1 < len(out) and out[idx + 1] == "(") else out[idx + 1:idx + 2]
|
||||
if not left_tokens or not right_tokens:
|
||||
raise ValueError("contains requires two operands")
|
||||
|
||||
left_str = " ".join(left_tokens)
|
||||
right_str = " ".join(right_tokens)
|
||||
|
||||
# Синтезируем вызов и заменяем диапазон
|
||||
new_tok = f"contains({left_str}, {right_str})"
|
||||
out = out[:lstart] + [new_tok] + out[(rend + 1) if (idx + 1 < len(out) and out[idx + 1] == "(") else (idx + 2):]
|
||||
return out
|
||||
|
||||
|
||||
def _tokenize_condition_expr(expr: str, context: Dict[str, Any], out_map: Dict[str, Any]) -> tuple[List[str], Dict[str, Any]]:
|
||||
tokens: List[str] = []
|
||||
bindings: Dict[str, Any] = {}
|
||||
i = 0
|
||||
n = len(expr)
|
||||
vcount = 0
|
||||
|
||||
def add_binding(val: Any) -> str:
|
||||
nonlocal vcount
|
||||
name = f"__v{vcount}"
|
||||
vcount += 1
|
||||
bindings[name] = val
|
||||
return name
|
||||
|
||||
while i < n:
|
||||
ch = expr[i]
|
||||
# Пробелы
|
||||
if ch.isspace():
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Операторы двойные
|
||||
if expr.startswith("&&", i):
|
||||
tokens.append("and")
|
||||
i += 2
|
||||
continue
|
||||
if expr.startswith("||", i):
|
||||
tokens.append("or")
|
||||
i += 2
|
||||
continue
|
||||
if expr.startswith(">=", i) or expr.startswith("<=", i) or expr.startswith("==", i) or expr.startswith("!=", i):
|
||||
tokens.append(expr[i:i+2])
|
||||
i += 2
|
||||
continue
|
||||
|
||||
# Одинарные операторы
|
||||
if ch in "()<>":
|
||||
tokens.append(ch)
|
||||
i += 1
|
||||
continue
|
||||
if ch == "!":
|
||||
# уже обработали "!=" как двойной
|
||||
tokens.append("not")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Строковые литералы "...." и '....' (простая версия: без экранирования)
|
||||
if ch == '"':
|
||||
j = i + 1
|
||||
while j < n and expr[j] != '"':
|
||||
# простая версия: без экранирования
|
||||
j += 1
|
||||
if j >= n:
|
||||
raise ValueError('Unterminated string literal')
|
||||
content = expr[i+1:j]
|
||||
# Конвертируем в безопасный Python-литерал
|
||||
tokens.append(repr(content))
|
||||
i = j + 1
|
||||
continue
|
||||
# Одинарные кавычки
|
||||
if ch == "'":
|
||||
j = i + 1
|
||||
while j < n and expr[j] != "'":
|
||||
# простая версия: без экранирования
|
||||
j += 1
|
||||
if j >= n:
|
||||
raise ValueError('Unterminated string literal')
|
||||
content = expr[i+1:j]
|
||||
tokens.append(repr(content))
|
||||
i = j + 1
|
||||
continue
|
||||
|
||||
# Макросы [[...]]
|
||||
if expr.startswith("[[", i):
|
||||
j = expr.find("]]", i + 2)
|
||||
if j < 0:
|
||||
raise ValueError("Unterminated [[...]] macro")
|
||||
body = expr[i+2:j]
|
||||
val = _resolve_square_macro_value(body, context, out_map)
|
||||
name = add_binding(val)
|
||||
tokens.append(name)
|
||||
i = j + 2
|
||||
continue
|
||||
|
||||
# Скобки {{ ... }}
|
||||
if expr.startswith("{{", i):
|
||||
j = expr.find("}}", i + 2)
|
||||
if j < 0:
|
||||
raise ValueError("Unterminated {{ ... }} expression")
|
||||
body = expr[i+2:j]
|
||||
val = _resolve_braces_value(body, context, out_map)
|
||||
name = add_binding(val)
|
||||
tokens.append(name)
|
||||
i = j + 2
|
||||
continue
|
||||
|
||||
# Ключевое слово contains
|
||||
if expr[i:i+8].lower() == "contains":
|
||||
tokens.append("contains")
|
||||
i += 8
|
||||
continue
|
||||
|
||||
# Число
|
||||
if ch.isdigit():
|
||||
j = i + 1
|
||||
dot_seen = False
|
||||
while j < n and (expr[j].isdigit() or (expr[j] == "." and not dot_seen)):
|
||||
if expr[j] == ".":
|
||||
dot_seen = True
|
||||
j += 1
|
||||
tokens.append(expr[i:j])
|
||||
i = j
|
||||
continue
|
||||
|
||||
# Идентификатор (на всякий — пропускаем последовательность букв/подчёрк/цифр)
|
||||
if ch.isalpha() or ch == "_":
|
||||
j = i + 1
|
||||
while j < n and (expr[j].isalnum() or expr[j] in "._"):
|
||||
j += 1
|
||||
word = expr[i:j]
|
||||
# Логические в словах не поддерживаем (используйте &&, ||, !)
|
||||
tokens.append(word)
|
||||
i = j
|
||||
continue
|
||||
|
||||
# Иное — ошибка
|
||||
raise ValueError(f"Unexpected character in expression: {ch!r}")
|
||||
|
||||
return tokens, bindings
|
||||
|
||||
|
||||
def _resolve_square_macro_value(body: str, context: Dict[str, Any], out_map: Dict[str, Any]) -> Any:
|
||||
# Тело без [[...]]
|
||||
b = str(body or "").strip()
|
||||
# [[OUT1]]
|
||||
m = re.fullmatch(r"(?is)OUT\s*(\d+)", b)
|
||||
if m:
|
||||
try:
|
||||
num = int(m.group(1))
|
||||
node_id = f"n{num}"
|
||||
node_out = out_map.get(node_id)
|
||||
return _best_text_from_outputs(node_out)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
# [[VAR: ...]]
|
||||
m = re.fullmatch(r"(?is)VAR\s*[:]\s*(.+)", b)
|
||||
if m:
|
||||
path = m.group(1).strip()
|
||||
return _get_by_path(context, path)
|
||||
|
||||
# [[OUT: node.path]]
|
||||
m = re.fullmatch(r"(?is)OUT\s*[:]\s*(.+)", b)
|
||||
if m:
|
||||
body2 = m.group(1).strip()
|
||||
if "." in body2:
|
||||
node_id, rest = body2.split(".", 1)
|
||||
node_val = out_map.get(node_id.strip())
|
||||
return _get_by_path(node_val, rest.strip())
|
||||
return out_map.get(body2)
|
||||
|
||||
# [[STORE: path]]
|
||||
m = re.fullmatch(r"(?is)STORE\s*[:]\s*(.+)", b)
|
||||
if m:
|
||||
path = m.group(1).strip()
|
||||
store = context.get("store") or {}
|
||||
return _get_by_path(store, path)
|
||||
|
||||
# [[NAME]] — «голая» переменная: сначала vars, потом context по пути/ключу
|
||||
name = b
|
||||
vmap = context.get("vars") or {}
|
||||
if isinstance(vmap, dict) and (name in vmap):
|
||||
return vmap.get(name)
|
||||
return _get_by_path(context, name)
|
||||
|
||||
|
||||
def _resolve_braces_value(body: str, context: Dict[str, Any], out_map: Dict[str, Any]) -> Any:
|
||||
# Логика совместима с {{ path|default(value) }}, возврат — типобезопасный
|
||||
expr = str(body or "").strip()
|
||||
|
||||
def eval_path(p: str) -> Any:
|
||||
p = p.strip()
|
||||
vmap = context.get("vars") or {}
|
||||
# Простой идентификатор — сначала в vars
|
||||
if re.fullmatch(r"[A-Za-z_][A-Za-z0-9_]*", p) and isinstance(vmap, dict) and p in vmap:
|
||||
return vmap.get(p)
|
||||
if p.startswith("OUT."):
|
||||
body2 = p[4:].strip()
|
||||
if "." in body2:
|
||||
node_id, rest = body2.split(".", 1)
|
||||
node_val = out_map.get(node_id.strip())
|
||||
return _get_by_path(node_val, rest.strip())
|
||||
return out_map.get(body2)
|
||||
return _get_by_path(context, p)
|
||||
|
||||
m = re.match(r"([^|]+)\|\s*default\((.*)\)\s*$", expr)
|
||||
if m:
|
||||
base_path = m.group(1).strip()
|
||||
fallback_raw = m.group(2).strip()
|
||||
|
||||
def eval_default(raw: str) -> Any:
|
||||
raw = raw.strip()
|
||||
dm = re.match(r"([^|]+)\|\s*default\((.*)\)\s*$", raw)
|
||||
if dm:
|
||||
base2 = dm.group(1).strip()
|
||||
fb2 = dm.group(2).strip()
|
||||
v2 = eval_path(base2)
|
||||
if v2 not in (None, ""):
|
||||
return v2
|
||||
return eval_default(fb2)
|
||||
# Пробуем как путь
|
||||
v = eval_path(raw)
|
||||
if v not in (None, ""):
|
||||
return v
|
||||
# Строка в кавычках
|
||||
if len(raw) >= 2 and ((raw[0] == '"' and raw[-1] == '"') or (raw[0] == "'" and raw[-1] == "'")):
|
||||
return raw[1:-1]
|
||||
# JSON литерал
|
||||
try:
|
||||
return json.loads(raw)
|
||||
except Exception:
|
||||
return raw
|
||||
|
||||
raw_val = eval_path(base_path)
|
||||
return raw_val if raw_val not in (None, "") else eval_default(fallback_raw)
|
||||
else:
|
||||
return eval_path(expr)
|
||||
|
||||
|
||||
def _stringify_for_contains(val: Any) -> str:
|
||||
# Для contains на строках — совместим со строкификацией шаблона
|
||||
return _stringify_for_template(val)
|
||||
|
||||
|
||||
def _safe_eval_bool(py_expr: str, bindings: Dict[str, Any]) -> bool:
|
||||
import ast
|
||||
import operator as op
|
||||
|
||||
def contains_fn(a: Any, b: Any) -> bool:
|
||||
# Семантика: список/кортеж/множество — membership, иначе — подстрока по строковому представлению
|
||||
if isinstance(a, (list, tuple, set)):
|
||||
return b in a
|
||||
sa = _stringify_for_contains(a)
|
||||
sb = _stringify_for_contains(b)
|
||||
return sb in sa
|
||||
|
||||
allowed_boolops = {ast.And, ast.Or}
|
||||
allowed_unary = {ast.Not}
|
||||
allowed_cmp = {ast.Eq: op.eq, ast.NotEq: op.ne, ast.Lt: op.lt, ast.LtE: op.le, ast.Gt: op.gt, ast.GtE: op.ge}
|
||||
|
||||
def eval_node(node: ast.AST) -> Any:
|
||||
if isinstance(node, ast.Expression):
|
||||
return eval_node(node.body)
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
if isinstance(node, ast.Name):
|
||||
if node.id == "contains":
|
||||
# Возврат специальной метки, реально обрабатывается в Call
|
||||
return ("__fn__", "contains")
|
||||
if node.id in bindings:
|
||||
return bindings[node.id]
|
||||
# Неизвестные имена запрещены
|
||||
raise ValueError(f"Unknown name: {node.id}")
|
||||
if isinstance(node, ast.UnaryOp) and isinstance(node.op, tuple(allowed_unary)):
|
||||
val = bool(eval_node(node.operand))
|
||||
if isinstance(node.op, ast.Not):
|
||||
return (not val)
|
||||
if isinstance(node, ast.BoolOp) and isinstance(node.op, tuple(allowed_boolops)):
|
||||
vals = [bool(eval_node(v)) for v in node.values]
|
||||
if isinstance(node.op, ast.And):
|
||||
res = True
|
||||
for v in vals:
|
||||
res = res and v
|
||||
return res
|
||||
if isinstance(node.op, ast.Or):
|
||||
res = False
|
||||
for v in vals:
|
||||
res = res or v
|
||||
return res
|
||||
if isinstance(node, ast.Compare):
|
||||
left = eval_node(node.left)
|
||||
for opnode, comparator in zip(node.ops, node.comparators):
|
||||
if type(opnode) not in allowed_cmp:
|
||||
raise ValueError("Unsupported comparison operator")
|
||||
right = eval_node(comparator)
|
||||
if not allowed_cmp[type(opnode)](left, right):
|
||||
return False
|
||||
left = right
|
||||
return True
|
||||
if isinstance(node, ast.Call):
|
||||
# Разрешаем только contains(a,b)
|
||||
if node.keywords or len(node.args) != 2:
|
||||
raise ValueError("Only contains(a,b) call is allowed")
|
||||
fn = node.func
|
||||
# Форма contains(...) может прийти как Name('contains') или как ("__fn__","contains")
|
||||
if isinstance(fn, ast.Name) and fn.id == "contains":
|
||||
a = eval_node(node.args[0])
|
||||
b = eval_node(node.args[1])
|
||||
return contains_fn(a, b)
|
||||
# Дополнительно: если парсер вернул константу-маркер
|
||||
if isinstance(fn, ast.Constant) and fn.value == ("__fn__", "contains"):
|
||||
a = eval_node(node.args[0])
|
||||
b = eval_node(node.args[1])
|
||||
return contains_fn(a, b)
|
||||
raise ValueError("Function calls are not allowed")
|
||||
# Запрещаем имена, атрибуты, индексации и прочее
|
||||
raise ValueError("Expression construct not allowed")
|
||||
|
||||
try:
|
||||
tree = ast.parse(py_expr, mode="eval")
|
||||
except Exception as exc:
|
||||
raise ValueError(f"Condition parse error: {exc}") from exc
|
||||
return bool(eval_node(tree))
|
||||
Reference in New Issue
Block a user