This commit is contained in:
2025-09-11 17:27:15 +03:00
parent 3c77c3dc2e
commit 11a0535712
32 changed files with 4682 additions and 442 deletions

View File

@@ -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))