had
This commit is contained in:
@@ -4,10 +4,18 @@ from typing import Any, Dict
|
||||
def default_pipeline() -> Dict[str, Any]:
|
||||
# Минимальный дефолт без устаревших нод.
|
||||
# Если пайплайн пустой, сервер вернёт echo-ответ (см. server.execute_pipeline_echo).
|
||||
# Добавлены поля управления режимом исполнения:
|
||||
# - loop_mode: "dag" | "iterative" (по умолчанию "dag")
|
||||
# - loop_max_iters: максимальное число запусков задач (safety)
|
||||
# - loop_time_budget_ms: ограничение по времени (safety)
|
||||
return {
|
||||
"id": "pipeline_default",
|
||||
"name": "Default Chat Pipeline",
|
||||
"parallel_limit": 8,
|
||||
"loop_mode": "dag",
|
||||
"loop_max_iters": 1000,
|
||||
"loop_time_budget_ms": 10000,
|
||||
"clear_var_store": True,
|
||||
"nodes": []
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import json
|
||||
import re
|
||||
import asyncio
|
||||
import time
|
||||
import hashlib
|
||||
from collections import deque
|
||||
from agentui.providers.http_client import build_client
|
||||
from agentui.common.vendors import detect_vendor
|
||||
from agentui.pipeline.templating import (
|
||||
@@ -20,7 +22,9 @@ from agentui.pipeline.templating import (
|
||||
_deep_find_text,
|
||||
_best_text_from_outputs,
|
||||
render_template_simple,
|
||||
eval_condition_expr,
|
||||
)
|
||||
from agentui.pipeline.storage import load_var_store, save_var_store, clear_var_store
|
||||
|
||||
|
||||
# --- Templating helpers are imported from agentui.pipeline.templating ---
|
||||
@@ -133,17 +137,75 @@ class PipelineExecutor:
|
||||
raise ExecutionError(f"Unknown node type: {n.get('type')}")
|
||||
self.nodes_by_id[n["id"]] = node_cls(n["id"], n.get("config", {}))
|
||||
|
||||
# Настройки режима исполнения (с дефолтами из default_pipeline)
|
||||
try:
|
||||
self.loop_mode = str(self.pipeline.get("loop_mode", "dag")).lower().strip() or "dag"
|
||||
except Exception:
|
||||
self.loop_mode = "dag"
|
||||
try:
|
||||
self.loop_max_iters = int(self.pipeline.get("loop_max_iters", 1000))
|
||||
except Exception:
|
||||
self.loop_max_iters = 1000
|
||||
try:
|
||||
self.loop_time_budget_ms = int(self.pipeline.get("loop_time_budget_ms", 10000))
|
||||
except Exception:
|
||||
self.loop_time_budget_ms = 10000
|
||||
|
||||
# Идентификатор пайплайна и политика очистки стора переменных
|
||||
try:
|
||||
self.pipeline_id = str(self.pipeline.get("id", "pipeline_editor")) or "pipeline_editor"
|
||||
except Exception:
|
||||
self.pipeline_id = "pipeline_editor"
|
||||
try:
|
||||
self.clear_var_store = bool(self.pipeline.get("clear_var_store", True))
|
||||
except Exception:
|
||||
self.clear_var_store = True
|
||||
|
||||
# В памяти держим актуальный STORE (доступен в шаблонах через {{ store.* }} и [[STORE:*]])
|
||||
self._store: Dict[str, Any] = {}
|
||||
# Локальный журнал выполнения для человекочитаемого трейсинга
|
||||
self._exec_log: List[str] = []
|
||||
|
||||
async def run(
|
||||
self,
|
||||
context: Dict[str, Any],
|
||||
trace: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Исполнитель пайплайна с динамическим порядком на основе зависимостей графа.
|
||||
Новый режим: волновое (level-by-level) исполнение с параллелизмом и барьером.
|
||||
Все узлы «готовой волны» стартуют параллельно, ждём всех, затем открывается следующая волна.
|
||||
Ограничение параллелизма берётся из pipeline.parallel_limit (по умолчанию 8).
|
||||
Политика ошибок: fail-fast — при исключении любой задачи волны прерываем пайплайн.
|
||||
Точка входа исполнителя. Переключает режим между:
|
||||
- "dag": волновое исполнение с барьерами (исходное поведение)
|
||||
- "iterative": итеративное исполнение с очередью и гейтами
|
||||
"""
|
||||
# Инициализация STORE по политике
|
||||
try:
|
||||
if self.clear_var_store:
|
||||
clear_var_store(self.pipeline_id)
|
||||
self._store = {}
|
||||
else:
|
||||
self._store = load_var_store(self.pipeline_id) or {}
|
||||
except Exception:
|
||||
self._store = {}
|
||||
|
||||
mode = (self.loop_mode or "dag").lower()
|
||||
if mode == "iterative":
|
||||
res = await self._run_iterative(context, trace)
|
||||
else:
|
||||
res = await self._run_dag(context, trace)
|
||||
|
||||
# На всякий случай финальная запись стора (если были изменения)
|
||||
try:
|
||||
save_var_store(self.pipeline_id, self._store)
|
||||
except Exception:
|
||||
pass
|
||||
return res
|
||||
|
||||
async def _run_dag(
|
||||
self,
|
||||
context: Dict[str, Any],
|
||||
trace: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Исходная волновая модель (DAG) — без изменений поведения.
|
||||
"""
|
||||
nodes: List[Dict[str, Any]] = list(self.pipeline.get("nodes", []))
|
||||
id_set = set(self.nodes_by_id.keys())
|
||||
@@ -151,6 +213,8 @@ class PipelineExecutor:
|
||||
# Собираем зависимости: node_id -> set(parent_ids), и обратные связи dependents
|
||||
deps_map: Dict[str, set] = {n["id"]: set() for n in nodes}
|
||||
dependents: Dict[str, set] = {n["id"]: set() for n in nodes}
|
||||
# Гейты для ветвлений: child_id -> List[(parent_id, gate_name)] (gate_name: "true"/"false")
|
||||
gate_deps: Dict[str, List[tuple[str, str]]] = {n["id"]: [] for n in nodes}
|
||||
|
||||
for n in nodes:
|
||||
nid = n["id"]
|
||||
@@ -176,8 +240,21 @@ class PipelineExecutor:
|
||||
# Ссылки вида "node.outKey" или "node"
|
||||
src_id = src.split(".", 1)[0] if "." in src else src
|
||||
if src_id in id_set:
|
||||
deps_map[nid].add(src_id)
|
||||
dependents[src_id].add(nid)
|
||||
# Если указали конкретный outKey (например, nIf.true / nIf.false),
|
||||
# трактуем это ТОЛЬКО как гейт (НЕ как топологическую зависимость), чтобы избежать дедлоков.
|
||||
is_gate = False
|
||||
if "." in src:
|
||||
try:
|
||||
_, out_name = src.split(".", 1)
|
||||
on = str(out_name).strip().lower()
|
||||
if on in {"true", "false"}:
|
||||
gate_deps[nid].append((src_id, on))
|
||||
is_gate = True
|
||||
except Exception:
|
||||
is_gate = False
|
||||
if not is_gate:
|
||||
deps_map[nid].add(src_id)
|
||||
dependents[src_id].add(nid)
|
||||
|
||||
# Входящие степени и первая волна
|
||||
in_degree: Dict[str, int] = {nid: len(deps) for nid, deps in deps_map.items()}
|
||||
@@ -186,11 +263,10 @@ class PipelineExecutor:
|
||||
processed: List[str] = []
|
||||
values: Dict[str, Dict[str, Any]] = {}
|
||||
last_result: Dict[str, Any] = {}
|
||||
last_node_id: Optional[str] = None
|
||||
node_def_by_id: Dict[str, Dict[str, Any]] = {n["id"]: n for n in nodes}
|
||||
# Накопитель пользовательских переменных (SetVars) — доступен как context["vars"]
|
||||
user_vars: Dict[str, Any] = {}
|
||||
|
||||
# Параметры параллелизма
|
||||
try:
|
||||
parallel_limit = int(self.pipeline.get("parallel_limit", 8))
|
||||
except Exception:
|
||||
@@ -198,23 +274,24 @@ class PipelineExecutor:
|
||||
if parallel_limit <= 0:
|
||||
parallel_limit = 1
|
||||
|
||||
# Вспомогательная корутина исполнения одной ноды со снапшотом OUT
|
||||
async def exec_one(node_id: str, values_snapshot: Dict[str, Any], wave_num: int) -> tuple[str, Dict[str, Any]]:
|
||||
ndef = node_def_by_id.get(node_id)
|
||||
if not ndef:
|
||||
raise ExecutionError(f"Node definition not found: {node_id}")
|
||||
node = self.nodes_by_id[node_id]
|
||||
|
||||
# Снимок контекста и OUT на момент старта волны
|
||||
|
||||
ctx = dict(context)
|
||||
ctx["OUT"] = values_snapshot
|
||||
# Пользовательские переменные (накопленные SetVars)
|
||||
try:
|
||||
ctx["vars"] = dict(user_vars)
|
||||
except Exception:
|
||||
ctx["vars"] = {}
|
||||
|
||||
# Разрешаем inputs для ноды
|
||||
# STORE доступен в шаблонах
|
||||
try:
|
||||
ctx["store"] = dict(self._store)
|
||||
except Exception:
|
||||
ctx["store"] = {}
|
||||
|
||||
inputs: Dict[str, Any] = {}
|
||||
for name, source in (ndef.get("in") or {}).items():
|
||||
if isinstance(source, list):
|
||||
@@ -222,7 +299,6 @@ class PipelineExecutor:
|
||||
else:
|
||||
inputs[name] = _resolve_in_value(source, ctx, values_snapshot)
|
||||
|
||||
# Трассировка старта
|
||||
if trace is not None:
|
||||
try:
|
||||
await trace({"event": "node_start", "node_id": ndef["id"], "wave": wave_num, "ts": int(time.time() * 1000)})
|
||||
@@ -230,6 +306,10 @@ class PipelineExecutor:
|
||||
pass
|
||||
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
print(f"TRACE start: {ndef['id']} ({node.type_name})")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out = await node.run(inputs, ctx)
|
||||
except Exception as exc:
|
||||
@@ -258,35 +338,58 @@ class PipelineExecutor:
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
prev_text = ""
|
||||
try:
|
||||
from agentui.pipeline.templating import _best_text_from_outputs as _bt # type: ignore
|
||||
if isinstance(out, dict):
|
||||
prev_text = _bt(out) or ""
|
||||
except Exception:
|
||||
prev_text = ""
|
||||
if isinstance(prev_text, str) and len(prev_text) > 200:
|
||||
prev_text = prev_text[:200]
|
||||
print(f"TRACE done: {ndef['id']} ({node.type_name}) dur={dur_ms}ms text={prev_text!r}")
|
||||
except Exception:
|
||||
pass
|
||||
return node_id, out
|
||||
|
||||
# Волновое исполнение
|
||||
wave_idx = 0
|
||||
while ready:
|
||||
wave_nodes = list(ready)
|
||||
ready = [] # будет заполнено после завершения волны
|
||||
ready = []
|
||||
wave_results: Dict[str, Dict[str, Any]] = {}
|
||||
# Один общий снапшот OUT для всей волны (барьер — узлы волны не видят результаты друг друга)
|
||||
values_snapshot = dict(values)
|
||||
|
||||
# Чанковый запуск с лимитом parallel_limit
|
||||
|
||||
for i in range(0, len(wave_nodes), parallel_limit):
|
||||
chunk = wave_nodes[i : i + parallel_limit]
|
||||
# fail-fast: при исключении любой задачи gather бросит и отменит остальные
|
||||
results = await asyncio.gather(
|
||||
*(exec_one(nid, values_snapshot, wave_idx) for nid in chunk),
|
||||
return_exceptions=False,
|
||||
)
|
||||
# Коммитим результаты чанка в локальное хранилище волны
|
||||
for nid, out in results:
|
||||
wave_results[nid] = out
|
||||
last_result = out # обновляем на каждом успешном результате
|
||||
|
||||
# После завершения волны — коммитим все её результаты в общие values
|
||||
last_result = out
|
||||
last_node_id = nid
|
||||
try:
|
||||
ndef = node_def_by_id.get(nid) or {}
|
||||
ntype = ndef.get("type", "")
|
||||
if ntype == "If":
|
||||
expr = str((ndef.get("config") or {}).get("expr", ""))
|
||||
resb = False
|
||||
try:
|
||||
if isinstance(out, dict):
|
||||
resb = bool(out.get("result"))
|
||||
except Exception:
|
||||
resb = False
|
||||
self._exec_log.append(f"If (#{nid}) {expr} => {str(resb).lower()}")
|
||||
else:
|
||||
self._exec_log.append(f"{nid}({ntype})")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
values.update(wave_results)
|
||||
processed.extend(wave_nodes)
|
||||
|
||||
# Соберём пользовательские переменные из SetVars узлов волны
|
||||
|
||||
try:
|
||||
for _nid, out in wave_results.items():
|
||||
if isinstance(out, dict):
|
||||
@@ -295,23 +398,553 @@ class PipelineExecutor:
|
||||
user_vars.update(v)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Обновляем входящие степени для зависимых и формируем следующую волну
|
||||
|
||||
# Обновляем и сохраняем STORE, если были новые переменные из SetVars/прочих нод
|
||||
try:
|
||||
merged: Dict[str, Any] = {}
|
||||
for _nid, out in wave_results.items():
|
||||
if isinstance(out, dict) and isinstance(out.get("vars"), dict):
|
||||
merged.update(out.get("vars") or {})
|
||||
if merged:
|
||||
self._store.update(merged)
|
||||
try:
|
||||
save_var_store(self.pipeline_id, self._store)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for done in wave_nodes:
|
||||
for child in dependents.get(done, ()):
|
||||
in_degree[child] -= 1
|
||||
next_ready = [nid for nid, deg in in_degree.items() if deg == 0 and nid not in processed and nid not in wave_nodes]
|
||||
# Исключаем уже учтённые и добавляем только те, которые действительно готовы
|
||||
ready = next_ready
|
||||
next_ready_candidates = [nid for nid, deg in in_degree.items() if deg == 0 and nid not in processed and nid not in wave_nodes]
|
||||
|
||||
def _gates_ok(child_id: str) -> bool:
|
||||
pairs = gate_deps.get(child_id) or []
|
||||
if not pairs:
|
||||
return True
|
||||
missing_seen = False
|
||||
for pid, gate in pairs:
|
||||
v = values.get(pid)
|
||||
if v is None:
|
||||
# значение гейта ещё не известно
|
||||
missing_seen = True
|
||||
continue
|
||||
try:
|
||||
flag = bool(isinstance(v, dict) and v.get(gate))
|
||||
except Exception:
|
||||
flag = False
|
||||
if not flag:
|
||||
return False
|
||||
# Семантика "missing gate = pass" как в iterative-режиме:
|
||||
# если флаги гейта ещё не известны, но у ребёнка есть реальные родители — допускаем первый запуск.
|
||||
# если реальных родителей нет (только гейты) — ждём появления значения гейта.
|
||||
if missing_seen:
|
||||
real_parents = deps_map.get(child_id) or set()
|
||||
if len(real_parents) == 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
ready = [nid for nid in next_ready_candidates if _gates_ok(nid)]
|
||||
wave_idx += 1
|
||||
|
||||
# Проверка на циклы/недостижимые ноды
|
||||
if len(processed) != len(nodes):
|
||||
remaining = [n["id"] for n in nodes if n["id"] not in processed]
|
||||
raise ExecutionError(f"Cycle detected or unresolved dependencies among nodes: {remaining}")
|
||||
|
||||
# Persist snapshot of macro context and outputs into STORE
|
||||
try:
|
||||
self._commit_snapshot(context, values, last_node_id or "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Построение и сохранение человекочитаемого EXECUTION TRACE
|
||||
try:
|
||||
summary = " -> ".join(self._exec_log) if getattr(self, "_exec_log", None) else ""
|
||||
if summary:
|
||||
try:
|
||||
print("===== EXECUTION TRACE =====")
|
||||
print(summary)
|
||||
print("===== END TRACE =====")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if isinstance(self._store.get("snapshot"), dict):
|
||||
self._store["snapshot"]["EXEC_TRACE"] = summary
|
||||
save_var_store(self.pipeline_id, self._store)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return last_result
|
||||
|
||||
async def _run_iterative(
|
||||
self,
|
||||
context: Dict[str, Any],
|
||||
trace: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Итеративное исполнение:
|
||||
- Очередь готовых задач
|
||||
- Повторные пуски допустимы: ребёнок ставится в очередь, когда какой-либо из его родителей обновился после последнего запуска ребёнка
|
||||
- Гейты If.true/If.false фильтруют постановку в очередь
|
||||
- Ограничения: loop_max_iters, loop_time_budget_ms
|
||||
"""
|
||||
nodes: List[Dict[str, Any]] = list(self.pipeline.get("nodes", []))
|
||||
id_set = set(self.nodes_by_id.keys())
|
||||
|
||||
# Строим зависимости и гейты (как в DAG)
|
||||
deps_map: Dict[str, set] = {n["id"]: set() for n in nodes}
|
||||
dependents: Dict[str, set] = {n["id"]: set() for n in nodes}
|
||||
gate_deps: Dict[str, List[tuple[str, str]]] = {n["id"]: [] for n in nodes}
|
||||
# Обратная карта для гейтов: кто «слушает» изменения конкретного родителя по гейту
|
||||
gate_dependents: Dict[str, set] = {n["id"]: set() for n in nodes}
|
||||
|
||||
for n in nodes:
|
||||
nid = n["id"]
|
||||
for _, source in (n.get("in") or {}).items():
|
||||
sources = source if isinstance(source, list) else [source]
|
||||
for src in sources:
|
||||
if not isinstance(src, str):
|
||||
continue
|
||||
if src.startswith("macro:"):
|
||||
continue
|
||||
if re.fullmatch(r"\[\[\s*VAR\s*[:\s]\s*[^\]]+\s*\]\]", src.strip()):
|
||||
continue
|
||||
out_ref_node = _extract_out_node_id_from_ref(src)
|
||||
if out_ref_node and out_ref_node in id_set:
|
||||
# [[OUT:nodeId(.key)*]] — это РЕАЛЬНАЯ топологическая зависимость
|
||||
deps_map[nid].add(out_ref_node)
|
||||
dependents[out_ref_node].add(nid)
|
||||
continue
|
||||
src_id = src.split(".", 1)[0] if "." in src else src
|
||||
if src_id in id_set:
|
||||
# "node.true/false" — ТОЛЬКО гейт (без топологической зависимости)
|
||||
is_gate = False
|
||||
if "." in src:
|
||||
try:
|
||||
_, out_name = src.split(".", 1)
|
||||
on = str(out_name).strip().lower()
|
||||
if on in {"true", "false"}:
|
||||
gate_deps[nid].append((src_id, on))
|
||||
# Регистрируем обратную зависимость по гейту,
|
||||
# чтобы будить ребёнка при изменении флага у родителя
|
||||
gate_dependents[src_id].add(nid)
|
||||
is_gate = True
|
||||
except Exception:
|
||||
is_gate = False
|
||||
if not is_gate:
|
||||
deps_map[nid].add(src_id)
|
||||
dependents[src_id].add(nid)
|
||||
|
||||
# Вспом. структуры состояния
|
||||
values: Dict[str, Dict[str, Any]] = {}
|
||||
last_result: Dict[str, Any] = {}
|
||||
last_node_id: Optional[str] = None
|
||||
user_vars: Dict[str, Any] = {}
|
||||
node_def_by_id: Dict[str, Dict[str, Any]] = {n["id"]: n for n in nodes}
|
||||
|
||||
# Версии обновлений нод (инкремент при каждом коммите)
|
||||
version: Dict[str, int] = {n["id"]: 0 for n in nodes}
|
||||
# Когда ребёнок запускался в последний раз (номер глобального шага)
|
||||
last_run_step: Dict[str, int] = {n["id"]: -1 for n in nodes}
|
||||
# Глобальный счётчик шагов
|
||||
step = 0
|
||||
|
||||
# Очередь готовых задач
|
||||
q: deque[str] = deque()
|
||||
|
||||
# Начальные готовые — ноды без зависимостей
|
||||
in_degree: Dict[str, int] = {nid: len(deps) for nid, deps in deps_map.items()}
|
||||
for nid, deg in in_degree.items():
|
||||
if deg == 0:
|
||||
q.append(nid)
|
||||
|
||||
def gates_ok(child_id: str) -> bool:
|
||||
pairs = gate_deps.get(child_id) or []
|
||||
if not pairs:
|
||||
return True
|
||||
missing_seen = False
|
||||
for pid, gate in pairs:
|
||||
v = values.get(pid)
|
||||
# Если значение гейта ещё не появилось — отмечаем и продолжаем проверку других гейтов
|
||||
if v is None:
|
||||
missing_seen = True
|
||||
continue
|
||||
try:
|
||||
flag = bool(isinstance(v, dict) and v.get(gate))
|
||||
except Exception:
|
||||
flag = False
|
||||
if not flag:
|
||||
return False
|
||||
# Если были отсутствующие значения гейтов, то:
|
||||
# - у узла есть реальные (топологические) родители → не блокируем (первый запуск допустим)
|
||||
# - у узла НЕТ реальных родителей (только гейты) → откладываем до появления значения гейта
|
||||
if missing_seen:
|
||||
real_parents = deps_map.get(child_id) or set()
|
||||
if len(real_parents) == 0:
|
||||
return False
|
||||
return True
|
||||
|
||||
# Лимиты
|
||||
total_runs = 0
|
||||
t0 = time.perf_counter()
|
||||
try:
|
||||
parallel_limit = int(self.pipeline.get("parallel_limit", 8))
|
||||
except Exception:
|
||||
parallel_limit = 8
|
||||
if parallel_limit <= 0:
|
||||
parallel_limit = 1
|
||||
|
||||
# Асинхронный запуск одной ноды (снимок OUT на момент dequeue)
|
||||
async def exec_one(node_id: str, snapshot: Dict[str, Any], wave_num: int) -> tuple[str, Dict[str, Any]]:
|
||||
ndef = node_def_by_id.get(node_id)
|
||||
if not ndef:
|
||||
raise ExecutionError(f"Node definition not found: {node_id}")
|
||||
node = self.nodes_by_id[node_id]
|
||||
|
||||
ctx = dict(context)
|
||||
ctx["OUT"] = snapshot
|
||||
try:
|
||||
ctx["vars"] = dict(user_vars)
|
||||
except Exception:
|
||||
ctx["vars"] = {}
|
||||
# STORE доступен в шаблонах
|
||||
try:
|
||||
ctx["store"] = dict(self._store)
|
||||
except Exception:
|
||||
ctx["store"] = {}
|
||||
|
||||
inputs: Dict[str, Any] = {}
|
||||
for name, source in (ndef.get("in") or {}).items():
|
||||
if isinstance(source, list):
|
||||
inputs[name] = [_resolve_in_value(s, ctx, snapshot) for s in source]
|
||||
else:
|
||||
inputs[name] = _resolve_in_value(source, ctx, snapshot)
|
||||
|
||||
if trace is not None:
|
||||
try:
|
||||
await trace({"event": "node_start", "node_id": ndef["id"], "wave": wave_num, "ts": int(time.time() * 1000)})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
started = time.perf_counter()
|
||||
try:
|
||||
print(f"TRACE start: {ndef['id']} ({node.type_name})")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
out = await node.run(inputs, ctx)
|
||||
except Exception as exc:
|
||||
if trace is not None:
|
||||
try:
|
||||
await trace({
|
||||
"event": "node_error",
|
||||
"node_id": ndef["id"],
|
||||
"wave": wave_num,
|
||||
"ts": int(time.time() * 1000),
|
||||
"error": str(exc),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
raise
|
||||
else:
|
||||
dur_ms = int((time.perf_counter() - started) * 1000)
|
||||
if trace is not None:
|
||||
try:
|
||||
await trace({
|
||||
"event": "node_done",
|
||||
"node_id": ndef["id"],
|
||||
"wave": wave_num,
|
||||
"ts": int(time.time() * 1000),
|
||||
"duration_ms": dur_ms,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
prev_text = ""
|
||||
try:
|
||||
from agentui.pipeline.templating import _best_text_from_outputs as _bt # type: ignore
|
||||
if isinstance(out, dict):
|
||||
prev_text = _bt(out) or ""
|
||||
except Exception:
|
||||
prev_text = ""
|
||||
if isinstance(prev_text, str) and len(prev_text) > 200:
|
||||
prev_text = prev_text[:200]
|
||||
print(f"TRACE done: {ndef['id']} ({node.type_name}) dur={dur_ms}ms text={prev_text!r}")
|
||||
except Exception:
|
||||
pass
|
||||
return node_id, out
|
||||
|
||||
# Главный цикл
|
||||
while q:
|
||||
# Проверяем лимиты
|
||||
if total_runs >= self.loop_max_iters:
|
||||
raise ExecutionError(f"Iterative mode exceeded loop_max_iters={self.loop_max_iters}")
|
||||
if (time.perf_counter() - t0) * 1000.0 > self.loop_time_budget_ms:
|
||||
raise ExecutionError(f"Iterative mode exceeded loop_time_budget_ms={self.loop_time_budget_ms}")
|
||||
|
||||
# Собираем партию до parallel_limit, с учётом гейтов.
|
||||
# ВАЖНО: защищаемся от «вечной карусели» в пределах одной партии.
|
||||
# Обрабатываем не более исходной длины очереди за проход.
|
||||
batch: List[str] = []
|
||||
seen: set[str] = set()
|
||||
spin_guard = len(q)
|
||||
processed_in_pass = 0
|
||||
while q and len(batch) < parallel_limit and processed_in_pass < spin_guard:
|
||||
nid = q.popleft()
|
||||
processed_in_pass += 1
|
||||
if nid in seen:
|
||||
# Уже добавлен в партию — пропускаем без возврата в очередь
|
||||
continue
|
||||
# Должны быть удовлетворены зависимости: или нет родителей, или все родители хотя бы раз исполнились
|
||||
parents = deps_map.get(nid) or set()
|
||||
deps_ok = all(version[p] > 0 or in_degree.get(nid, 0) == 0 for p in parents) or (len(parents) == 0)
|
||||
if not deps_ok:
|
||||
# отложим до лучших времён
|
||||
q.append(nid)
|
||||
continue
|
||||
if not gates_ok(nid):
|
||||
# гейты пока не открыты — попробуем позже
|
||||
q.append(nid)
|
||||
continue
|
||||
batch.append(nid)
|
||||
seen.add(nid)
|
||||
|
||||
if not batch:
|
||||
# Очередь «застряла» — либо циклические ожидания без начальных значений, либо гейт не откроется.
|
||||
# Безопасно выходим.
|
||||
break
|
||||
|
||||
# Снимок на момент партии
|
||||
snapshot = dict(values)
|
||||
results = await asyncio.gather(*(exec_one(nid, snapshot, step) for nid in batch), return_exceptions=False)
|
||||
|
||||
# Коммитим результаты немедленно и продвигаем версии
|
||||
produced_vars: Dict[str, Any] = {}
|
||||
for nid, out in results:
|
||||
values[nid] = out
|
||||
version[nid] = version.get(nid, 0) + 1
|
||||
last_result = out
|
||||
last_node_id = nid
|
||||
total_runs += 1
|
||||
last_run_step[nid] = step
|
||||
# Человекочитаемый журнал
|
||||
try:
|
||||
ndef = node_def_by_id.get(nid) or {}
|
||||
ntype = ndef.get("type", "")
|
||||
if ntype == "If":
|
||||
expr = str((ndef.get("config") or {}).get("expr", ""))
|
||||
resb = False
|
||||
try:
|
||||
if isinstance(out, dict):
|
||||
resb = bool(out.get("result"))
|
||||
except Exception:
|
||||
resb = False
|
||||
self._exec_log.append(f"If (#{nid}) {expr} => {str(resb).lower()}")
|
||||
else:
|
||||
self._exec_log.append(f"{nid}({ntype})")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Сбор пользовательских переменных
|
||||
try:
|
||||
if isinstance(out, dict) and isinstance(out.get("vars"), dict):
|
||||
produced_vars.update(out.get("vars") or {})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if produced_vars:
|
||||
user_vars.update(produced_vars)
|
||||
# Обновляем STORE и сохраняем на диск
|
||||
try:
|
||||
self._store.update(produced_vars)
|
||||
save_var_store(self.pipeline_id, self._store)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Постановка потомков, у кого изменился хотя бы один родитель
|
||||
for nid in batch:
|
||||
# Топологические потомки (реальные зависимости): будим всегда
|
||||
for child in dependents.get(nid, ()):
|
||||
if last_run_step.get(child, -1) < step:
|
||||
q.append(child)
|
||||
|
||||
# Потомки по гейтам: будим ТОЛЬКО выбранную ветку
|
||||
# Логика: если у gchild есть пара gate_deps с текущим nid и нужным gate ('true'/'false'),
|
||||
# то будим только если соответствующий флаг у родителя истинный. Иначе — не будим.
|
||||
g_out = values.get(nid) or {}
|
||||
g_children = gate_dependents.get(nid, ())
|
||||
for gchild in g_children:
|
||||
if last_run_step.get(gchild, -1) >= step:
|
||||
continue
|
||||
pairs = [p for p in (gate_deps.get(gchild) or []) if p and p[0] == nid]
|
||||
if not pairs:
|
||||
# gchild зарегистрирован как зависимый по гейту, но явной пары нет — пропускаем
|
||||
continue
|
||||
wake = False
|
||||
for (_pid, gate_name) in pairs:
|
||||
try:
|
||||
want = str(gate_name or "").strip().lower()
|
||||
if want in ("true", "false"):
|
||||
if bool(g_out.get(want, False)):
|
||||
wake = True
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if wake:
|
||||
q.append(gchild)
|
||||
try:
|
||||
print(f"TRACE wake_by_gate: parent={nid} -> child={gchild}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ранний выход, если встретили Return среди выполненных
|
||||
try:
|
||||
for nid in batch:
|
||||
ndef = node_def_by_id.get(nid) or {}
|
||||
if (ndef.get("type") or "") == "Return":
|
||||
# Зафиксируем, что именно Return завершил выполнение
|
||||
try:
|
||||
last_node_id = nid
|
||||
last_result = values.get(nid) or last_result
|
||||
except Exception:
|
||||
pass
|
||||
# Снимок стора до выхода
|
||||
try:
|
||||
self._commit_snapshot(context, values, last_node_id or "")
|
||||
except Exception:
|
||||
pass
|
||||
# Финальный EXECUTION TRACE в лог и STORE
|
||||
try:
|
||||
summary = " -> ".join(self._exec_log) if getattr(self, "_exec_log", None) else ""
|
||||
if summary:
|
||||
try:
|
||||
print("===== EXECUTION TRACE =====")
|
||||
print(summary)
|
||||
print("===== END TRACE =====")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if isinstance(self._store.get("snapshot"), dict):
|
||||
self._store["snapshot"]["EXEC_TRACE"] = summary
|
||||
save_var_store(self.pipeline_id, self._store)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
return last_result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
step += 1
|
||||
|
||||
# Финальный снапшот всего контекста и OUT в STORE.snapshot
|
||||
try:
|
||||
self._commit_snapshot(context, values, last_node_id or "")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Построение и сохранение человекочитаемого EXECUTION TRACE
|
||||
try:
|
||||
summary = " -> ".join(self._exec_log) if getattr(self, "_exec_log", None) else ""
|
||||
if summary:
|
||||
try:
|
||||
print("===== EXECUTION TRACE =====")
|
||||
print(summary)
|
||||
print("===== END TRACE =====")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
if isinstance(self._store.get("snapshot"), dict):
|
||||
self._store["snapshot"]["EXEC_TRACE"] = summary
|
||||
save_var_store(self.pipeline_id, self._store)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return last_result
|
||||
|
||||
|
||||
def _safe_preview(self, obj: Any, max_bytes: int = 262_144) -> Any:
|
||||
"""
|
||||
Вернёт объект как есть, если его JSON-представление укладывается в лимит.
|
||||
Иначе — мета-объект с пометкой о тримминге и SHA256 + текстовый превью.
|
||||
"""
|
||||
try:
|
||||
s = json.dumps(obj, ensure_ascii=False)
|
||||
except Exception:
|
||||
try:
|
||||
s = str(obj)
|
||||
except Exception:
|
||||
s = "<unrepresentable>"
|
||||
b = s.encode("utf-8", errors="ignore")
|
||||
if len(b) <= max_bytes:
|
||||
return obj
|
||||
sha = hashlib.sha256(b).hexdigest()
|
||||
preview = b[:max_bytes].decode("utf-8", errors="ignore")
|
||||
return {
|
||||
"__truncated__": True,
|
||||
"sha256": sha,
|
||||
"preview": preview,
|
||||
}
|
||||
|
||||
def _commit_snapshot(self, context: Dict[str, Any], values: Dict[str, Any], last_node_id: str) -> None:
|
||||
"""
|
||||
Собирает STORE.snapshot:
|
||||
- incoming.*, params.*, model, vendor_format, system
|
||||
- OUT_TEXT.nX — строковая вытяжка (до 16КБ)
|
||||
- OUT.nX — сырой JSON (с триммингом до ~256КБ через preview)
|
||||
- Алиасы OUT1/OUT2/... — плоские строки (то же, что [[OUT1]])
|
||||
- LAST_NODE — последний выполнившийся узел
|
||||
"""
|
||||
# OUT_TEXT / OUT
|
||||
out_text: Dict[str, str] = {}
|
||||
out_raw: Dict[str, Any] = {}
|
||||
aliases: Dict[str, Any] = {}
|
||||
|
||||
for nid, out in (values or {}).items():
|
||||
if out is None:
|
||||
continue
|
||||
try:
|
||||
txt = _best_text_from_outputs(out) # type: ignore[name-defined]
|
||||
except Exception:
|
||||
txt = ""
|
||||
if not isinstance(txt, str):
|
||||
try:
|
||||
txt = str(txt)
|
||||
except Exception:
|
||||
txt = ""
|
||||
if len(txt) > 16_384:
|
||||
txt = txt[:16_384]
|
||||
out_text[nid] = txt
|
||||
out_raw[nid] = self._safe_preview(out, 262_144)
|
||||
m = re.match(r"^n(\d+)$", str(nid))
|
||||
if m:
|
||||
aliases[f"OUT{int(m.group(1))}"] = txt
|
||||
|
||||
snapshot: Dict[str, Any] = {
|
||||
"incoming": self._safe_preview(context.get("incoming"), 262_144),
|
||||
"params": self._safe_preview(context.get("params"), 65_536),
|
||||
"model": context.get("model"),
|
||||
"vendor_format": context.get("vendor_format"),
|
||||
"system": context.get("system") or "",
|
||||
"OUT": out_raw,
|
||||
"OUT_TEXT": out_text,
|
||||
"LAST_NODE": last_node_id or "",
|
||||
**aliases,
|
||||
}
|
||||
|
||||
# Сохраняем под ключом snapshot, пользовательские vars остаются как есть в корне STORE
|
||||
try:
|
||||
if not isinstance(self._store, dict):
|
||||
self._store = {}
|
||||
self._store["snapshot"] = snapshot
|
||||
save_var_store(self.pipeline_id, self._store)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
class SetVarsNode(Node):
|
||||
type_name = "SetVars"
|
||||
@@ -1026,12 +1659,40 @@ class ReturnNode(Node):
|
||||
return {"result": result, "response_text": text}
|
||||
|
||||
|
||||
class IfNode(Node):
|
||||
type_name = "If"
|
||||
|
||||
async def run(self, inputs: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]: # noqa: D401
|
||||
expr = str(self.config.get("expr") or "").strip()
|
||||
out_map = context.get("OUT") or {}
|
||||
# Для логов: отображаем развёрнутое выражение (с подставленными макросами)
|
||||
try:
|
||||
expanded = render_template_simple(expr, context, out_map)
|
||||
except Exception:
|
||||
expanded = expr
|
||||
try:
|
||||
res = bool(eval_condition_expr(expr, context, out_map)) if expr else False
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# Расширенный лог, чтобы быстрее найти ошибку парсинга/оценки выражения
|
||||
try:
|
||||
print(f"TRACE if_error: {self.node_id} expr={expr!r} expanded={expanded!r} error={exc}")
|
||||
except Exception:
|
||||
pass
|
||||
raise ExecutionError(f"If expr error: {exc}")
|
||||
try:
|
||||
print(f"TRACE if: {self.node_id} expr={expr!r} expanded={expanded!r} result={str(res).lower()}")
|
||||
except Exception:
|
||||
pass
|
||||
return {"result": res, "true": res, "false": (not res)}
|
||||
|
||||
|
||||
NODE_REGISTRY.update({
|
||||
SetVarsNode.type_name: SetVarsNode,
|
||||
ProviderCallNode.type_name: ProviderCallNode,
|
||||
RawForwardNode.type_name: RawForwardNode,
|
||||
ReturnNode.type_name: ReturnNode,
|
||||
IfNode.type_name: IfNode,
|
||||
})
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ from agentui.pipeline.defaults import default_pipeline
|
||||
|
||||
PIPELINE_FILE = Path("pipeline.json")
|
||||
PRESETS_DIR = Path("presets")
|
||||
VARS_DIR = Path(".agentui") / "vars"
|
||||
|
||||
|
||||
def load_pipeline() -> Dict[str, Any]:
|
||||
@@ -42,3 +43,51 @@ def save_preset(name: str, pipeline: Dict[str, Any]) -> None:
|
||||
path.write_text(json.dumps(pipeline, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------- Variable Store (per-pipeline) ----------------
|
||||
|
||||
def _var_store_path(pipeline_id: str) -> Path:
|
||||
pid = pipeline_id or "pipeline_editor"
|
||||
VARS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# normalize to safe filename
|
||||
safe = "".join(ch if ch.isalnum() or ch in ("-", "_", ".") else "_" for ch in str(pid))
|
||||
return VARS_DIR / f"{safe}.json"
|
||||
|
||||
|
||||
def load_var_store(pipeline_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Load variable store dictionary for given pipeline id.
|
||||
Returns {} if not exists or invalid.
|
||||
"""
|
||||
path = _var_store_path(pipeline_id)
|
||||
if not path.exists():
|
||||
return {}
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
return data if isinstance(data, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def save_var_store(pipeline_id: str, data: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Save variable store dictionary for given pipeline id.
|
||||
"""
|
||||
path = _var_store_path(pipeline_id)
|
||||
try:
|
||||
VARS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
except Exception:
|
||||
pass
|
||||
path.write_text(json.dumps(data or {}, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
|
||||
def clear_var_store(pipeline_id: str) -> None:
|
||||
"""
|
||||
Delete/reset variable store for given pipeline id.
|
||||
"""
|
||||
path = _var_store_path(pipeline_id)
|
||||
try:
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
except Exception:
|
||||
# ignore failures
|
||||
pass
|
||||
|
||||
@@ -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