mirror of
https://github.com/langgenius/dify.git
synced 2026-11-11 13:51:25 +08:00
Track backend integration, Ruff cleanup and migration reconciliation in ENG-1271. Retain required copy contracts without implementing frontend interactions.
875 lines
39 KiB
Python
875 lines
39 KiB
Python
"""Pure, deterministic repairs for LLM-written workflow graph dicts.
|
|
|
|
Shared by two callers that must agree:
|
|
|
|
- ``services.workflow_generation.runner.WorkflowGenerator._postprocess_graph`` -- the
|
|
generator used by cmd+K ``/create`` / ``/refine`` and by the Dify Builder's
|
|
first build;
|
|
- ``services.dify_builder.dify_port.WorkflowServiceDifyPort.apply_repair`` --
|
|
the Builder's single write chokepoint, which also sees the Fix / Edit
|
|
intents the generator never produced.
|
|
|
|
No I/O, no engine imports beyond the graph dict shape, no prompts: every
|
|
function takes the raw ``nodes`` / ``edges`` lists the canvas persists and
|
|
mutates them in place, returning what it changed so callers can log it.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable, Mapping, MutableMapping
|
|
from typing import Any
|
|
|
|
from services.dify_builder.native_http_output_contract import native_http_variable_types
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Condition values (if-else/loop's Condition vs list-operator's FilterCondition)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# graphon ``Condition.value`` (if-else, loop) is ``str | Sequence[str] | bool | None``.
|
|
# graphon ``FilterCondition.value`` (list-operator) is ``str | Sequence[str] | bool``
|
|
# (no None; default ""). For list operators filtering arrays, graphon requires a plain
|
|
# string, not a list. The two need separate normalization rules.
|
|
|
|
# if-else/loop conditions: operators below compare against a LIST, so a scalar
|
|
# written for them must become a one-item list rather than a bare string.
|
|
_LIST_OPERATORS = frozenset({"in", "not in", "all of"})
|
|
|
|
# ASCII spellings of a comparison operator that can mean ONE engine literal and
|
|
# nothing else. graphon's ``SupportedComparisonOperator``
|
|
# (``utils/condition/entities.py``) and the list-operator's ``FilterOperator``
|
|
# (``nodes/list_operator/entities.py``) both spell the ordering comparisons
|
|
# ``≥`` / ``≤``; pydantic refuses ``>=`` / ``<=`` at ``Graph.init``, and an LLM
|
|
# writes them constantly (F4 cause (a): an Edit rewrote an if-else's ``cases``
|
|
# with ``">="`` and the whole batch was rejected).
|
|
#
|
|
# ONLY the two ordering forms are here. Every equality-family ASCII form is
|
|
# ambiguous: graphon's string equality is ``is`` / ``is not``
|
|
# (``processor._assert_is`` accepts ``str | bool``) while its number equality is
|
|
# ``=`` / ``≠`` (``_assert_equal`` accepts only numbers/bools), so ``==`` could
|
|
# be either and ``!=`` / ``<>`` likewise -- the same ambiguity that keeps the
|
|
# word forms (``equals``, ``gte``) rejected. The ordering forms carry none of
|
|
# it: graphon has no string ordering operator at all
|
|
# (``_assert_greater_than`` and its three siblings reject anything that is not a
|
|
# number), so ``>=`` can only mean ``≥``. Do not quote that rejection's wording
|
|
# here -- it contains a bare ``type`` marker that mypy reads as a type comment
|
|
# whenever a reflow leaves it leading a line, which silently kills the whole
|
|
# type check from this module outward.
|
|
# Guessing an equality would silently change which comparison the draft runs;
|
|
# a loud refusal is the right outcome there.
|
|
_ASCII_COMPARISON_OPERATORS: Mapping[str, str] = {
|
|
">=": "≥",
|
|
"<=": "≤",
|
|
}
|
|
|
|
|
|
def _scalar_to_text(value: int | float) -> str:
|
|
"""``60`` -> ``"60"``, ``60.0`` -> ``"60"``, ``60.5`` -> ``"60.5"``."""
|
|
if isinstance(value, float) and value.is_integer():
|
|
return str(int(value))
|
|
return str(value)
|
|
|
|
|
|
def _normalize_list_item(item: Any) -> str:
|
|
"""Normalize a single item in a condition value list."""
|
|
if isinstance(item, str):
|
|
return item
|
|
if isinstance(item, (int, float)):
|
|
return _scalar_to_text(item)
|
|
return str(item)
|
|
|
|
|
|
def normalize_condition_value(value: Any, operator: str) -> Any:
|
|
"""One condition ``value`` (if-else/loop Condition) as graphon will accept it.
|
|
|
|
ESQ1-303: the node builder wrote ``"value": 60`` (a JSON number). pydantic
|
|
rejects a number for ``str | Sequence[str] | bool | None`` -- except that it
|
|
coerces ``1`` / ``0`` to ``True`` / ``False``, which is why ``bool`` is
|
|
checked BEFORE ``int`` here (``True`` is an ``int`` in Python).
|
|
"""
|
|
if isinstance(value, bool) or value is None:
|
|
return value
|
|
if isinstance(value, list):
|
|
return [_normalize_list_item(item) for item in value]
|
|
if isinstance(value, (int, float)):
|
|
text = _scalar_to_text(value)
|
|
return [text] if operator in _LIST_OPERATORS else text
|
|
if isinstance(value, str) and operator in _LIST_OPERATORS:
|
|
return [value]
|
|
return value
|
|
|
|
|
|
def normalize_filter_condition_value(value: Any, operator: str = "") -> Any:
|
|
"""One filter condition ``value`` (list-operator FilterCondition) normalized.
|
|
|
|
FilterCondition.value is str | Sequence[str] | bool (default ""), NOT None.
|
|
Filtering an array of strings or numbers requires a plain string, not a list.
|
|
Booleans are kept. Numbers become text. Lists get their items normalized.
|
|
|
|
``operator`` is accepted and ignored: it gives this the same
|
|
``(value, operator)`` shape as ``normalize_condition_value`` so the
|
|
conditions walker calls both one way. Unlike a Condition, a scalar under
|
|
a list operator (``in`` / ``not in`` / ``all of``) is never wrapped in a
|
|
list here.
|
|
"""
|
|
if isinstance(value, bool):
|
|
return value
|
|
if value is None:
|
|
return ""
|
|
if isinstance(value, (int, float)):
|
|
return _scalar_to_text(value)
|
|
if isinstance(value, str):
|
|
return value
|
|
if isinstance(value, (list, tuple)):
|
|
return [_normalize_list_item(item) for item in value]
|
|
return value
|
|
|
|
|
|
def _normalize_condition_list(
|
|
conditions: Any,
|
|
value_normalizer: Callable[[Any, str], Any] = normalize_condition_value,
|
|
) -> bool:
|
|
"""Normalize every ``comparison_operator`` and ``value`` in a conditions
|
|
list (recursing into ``sub_variable_condition``). Returns True when
|
|
anything changed.
|
|
|
|
The operator is canonicalized FIRST (``_ASCII_COMPARISON_OPERATORS``),
|
|
because the value rule keys on it -- ``normalize_condition_value`` wraps a
|
|
scalar into a one-item list under ``in`` / ``not in`` / ``all of`` -- and
|
|
must never be shown an operator the engine is about to reject.
|
|
|
|
``value_normalizer`` is called as ``(value, comparison_operator)``:
|
|
``normalize_condition_value`` for if-else / loop Conditions,
|
|
``normalize_filter_condition_value`` (which ignores the operator) for
|
|
list-operator FilterConditions. Callers must pass the right one.
|
|
"""
|
|
changed = False
|
|
if not isinstance(conditions, list):
|
|
return False
|
|
for condition in conditions:
|
|
if not isinstance(condition, MutableMapping):
|
|
continue
|
|
operator = str(condition.get("comparison_operator") or "")
|
|
canonical = _ASCII_COMPARISON_OPERATORS.get(operator)
|
|
if canonical is not None:
|
|
condition["comparison_operator"] = canonical
|
|
operator = canonical
|
|
changed = True
|
|
if "value" in condition:
|
|
new_value = value_normalizer(condition["value"], operator)
|
|
if new_value != condition["value"] or type(new_value) is not type(condition["value"]):
|
|
condition["value"] = new_value
|
|
changed = True
|
|
sub = condition.get("sub_variable_condition")
|
|
if isinstance(sub, MutableMapping) and _normalize_condition_list(sub.get("conditions"), value_normalizer):
|
|
changed = True
|
|
return changed
|
|
|
|
|
|
def normalize_conditions(nodes: list[Any]) -> list[str]:
|
|
"""Coerce every condition operator and value in ``nodes`` to the shape
|
|
graphon accepts.
|
|
|
|
Covers if-else ``cases[].conditions[]`` (and the legacy top-level
|
|
``conditions``), loop ``break_conditions[]``, and list-operator
|
|
``filter_by.conditions[]``. if-else and loop use graphon's ``Condition``
|
|
model; list-operator uses ``FilterCondition`` (different value shape, same
|
|
operator spellings). Returns the ids of the nodes that changed, in order.
|
|
"""
|
|
changed: list[str] = []
|
|
for node in nodes:
|
|
if not isinstance(node, Mapping):
|
|
continue
|
|
data = node.get("data")
|
|
if not isinstance(data, MutableMapping):
|
|
continue
|
|
node_type = data.get("type")
|
|
touched = False
|
|
if node_type == "if-else":
|
|
cases = data.get("cases")
|
|
if isinstance(cases, list):
|
|
for case in cases:
|
|
if isinstance(case, Mapping) and _normalize_condition_list(
|
|
case.get("conditions"), normalize_condition_value
|
|
):
|
|
touched = True
|
|
if _normalize_condition_list(data.get("conditions"), normalize_condition_value):
|
|
touched = True
|
|
elif node_type == "loop":
|
|
touched = _normalize_condition_list(data.get("break_conditions"), normalize_condition_value)
|
|
elif node_type == "list-operator":
|
|
filter_by = data.get("filter_by")
|
|
if isinstance(filter_by, Mapping):
|
|
touched = _normalize_condition_list(filter_by.get("conditions"), normalize_filter_condition_value)
|
|
if touched:
|
|
changed.append(str(node.get("id") or ""))
|
|
return changed
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# if-else ``varType`` (frontend-only operator hint)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Start-variable ``type`` -> the canvas ``VarType`` it is certain to be. Anything
|
|
# not listed (``json``, ``json_object``, ``iterator``, ...) is left alone: the
|
|
# frontend copes with a missing ``varType`` (it defaults the operator list);
|
|
# a wrong one steers the user to operators that fail at run time (ESQ1-285).
|
|
_START_TYPE_TO_VAR_TYPE: Mapping[str, str] = {
|
|
"number": "number",
|
|
"text-input": "string",
|
|
"paragraph": "string",
|
|
"select": "string",
|
|
"url": "string",
|
|
"checkbox": "boolean",
|
|
"file": "file",
|
|
"file-list": "array[file]",
|
|
}
|
|
|
|
|
|
def _known_variable_types(nodes: list[Any]) -> dict[tuple[str, str], str]:
|
|
"""``(producer_node_id, variable_name) -> VarType`` for declared Start outputs."""
|
|
out: dict[tuple[str, str], str] = {}
|
|
for node in nodes:
|
|
if not isinstance(node, Mapping):
|
|
continue
|
|
data = node.get("data")
|
|
if not isinstance(data, Mapping):
|
|
continue
|
|
node_type = data.get("type")
|
|
node_id = str(node.get("id") or "")
|
|
if node_type == "start":
|
|
for variable in data.get("variables") or []:
|
|
if not isinstance(variable, Mapping):
|
|
continue
|
|
var_type = _START_TYPE_TO_VAR_TYPE.get(str(variable.get("type") or ""))
|
|
name = str(variable.get("variable") or "")
|
|
if var_type and name:
|
|
out[(node_id, name)] = var_type
|
|
return out
|
|
|
|
|
|
def derive_if_else_var_types(nodes: list[Any]) -> list[str]:
|
|
"""Fill missing ``varType`` only for certain declared Start and HTTP outputs.
|
|
|
|
HTTP hints apply to direct output roots only (two-part selectors); nested
|
|
JSON and other producer outputs remain unknown. Existing authored values
|
|
are never overwritten. Returns the ids of the nodes that changed.
|
|
"""
|
|
known = _known_variable_types(nodes)
|
|
http_known = dict(native_http_variable_types(nodes))
|
|
changed: list[str] = []
|
|
for node in nodes:
|
|
if not isinstance(node, Mapping):
|
|
continue
|
|
data = node.get("data")
|
|
if not isinstance(data, Mapping) or data.get("type") != "if-else":
|
|
continue
|
|
touched = False
|
|
for case in data.get("cases") or []:
|
|
if not isinstance(case, Mapping):
|
|
continue
|
|
for condition in case.get("conditions") or []:
|
|
if not isinstance(condition, MutableMapping) or condition.get("varType"):
|
|
continue
|
|
selector = condition.get("variable_selector")
|
|
if not isinstance(selector, list) or len(selector) != 2:
|
|
continue
|
|
var_type = known.get((str(selector[0]), str(selector[1])))
|
|
if not var_type and all(isinstance(part, str) for part in selector):
|
|
var_type = http_known.get((selector[0], selector[1]))
|
|
if var_type:
|
|
condition["varType"] = var_type
|
|
touched = True
|
|
if touched:
|
|
changed.append(str(node.get("id") or ""))
|
|
return changed
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# http-request bodies
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# graphon's executor (``Executor._require_single_body_item``) sends these body
|
|
# types as ONE rendered item; ``form-data`` / ``x-www-form-urlencoded`` take any
|
|
# number of key/value items and ``none`` takes none.
|
|
_SINGLE_ITEM_BODY_TYPES = frozenset({"json", "raw-text", "binary"})
|
|
|
|
|
|
def normalize_http_request_bodies(nodes: list[Any]) -> list[str]:
|
|
"""Fill what graphon's http-request models cannot default: body item
|
|
types and a missing ``authorization.type``.
|
|
|
|
``BodyData.type`` is ``Literal["file", "text"]`` with NO default, and the
|
|
shared builder prompt only ever shows ``"body": {"type": "none", "data": []}``,
|
|
so the LLM writes ``{key, value}`` items (ESQ1-302) and the graph fails at
|
|
``Graph.init``. An item with a non-empty ``file`` selector is a file item;
|
|
every other item is text. A ``none`` body carries no items.
|
|
|
|
An ``authorization`` dict with no ``type`` is healed too (see
|
|
``_normalize_http_authorization``). Returns the ids of the nodes that
|
|
changed.
|
|
"""
|
|
changed: list[str] = []
|
|
for node in nodes:
|
|
if not isinstance(node, Mapping):
|
|
continue
|
|
data = node.get("data")
|
|
if not isinstance(data, Mapping) or data.get("type") != "http-request":
|
|
continue
|
|
touched = _normalize_http_authorization(data.get("authorization"))
|
|
body = data.get("body")
|
|
items = body.get("data") if isinstance(body, MutableMapping) else None
|
|
if isinstance(body, MutableMapping) and body.get("type") == "none" and items:
|
|
body["data"] = []
|
|
touched = True
|
|
elif isinstance(items, list):
|
|
for item in items:
|
|
if not isinstance(item, MutableMapping) or item.get("type") in ("file", "text"):
|
|
continue
|
|
item["type"] = "file" if item.get("file") else "text"
|
|
touched = True
|
|
if touched:
|
|
changed.append(str(node.get("id") or ""))
|
|
return changed
|
|
|
|
|
|
def _normalize_http_authorization(authorization: Any) -> bool:
|
|
"""Give an http-request ``authorization`` dict that has no ``type`` the
|
|
one it evidently means: ``api-key`` when its ``config`` is a dict carrying
|
|
a non-empty ``api_key``, otherwise ``no-auth`` (with ``config: None``).
|
|
Returns True when it changed anything.
|
|
|
|
graphon's ``HttpRequestNodeAuthorization.type`` is required, and its
|
|
``check_config`` validator then reads ``values.data["type"]`` -- absent,
|
|
because the required field failed -- so a generated authorization without
|
|
``type`` raises KeyError out of validation (pydantic does not wrap it) and
|
|
crashed a live Builder session. This only turns that engine-refused shape
|
|
into an accepted one; an authorization that already has a ``type`` is
|
|
left exactly as written.
|
|
"""
|
|
if not isinstance(authorization, MutableMapping) or authorization.get("type"):
|
|
return False
|
|
config = authorization.get("config")
|
|
if isinstance(config, Mapping) and config.get("api_key"):
|
|
authorization["type"] = "api-key"
|
|
else:
|
|
authorization["type"] = "no-auth"
|
|
authorization["config"] = None
|
|
return True
|
|
|
|
|
|
def http_request_body_errors(nodes: list[Any]) -> list[tuple[str, str]]:
|
|
"""``(node_id, detail)`` for every http-request whose body the executor
|
|
would reject at run time: a json / raw-text / binary body with a number of
|
|
items other than one.
|
|
|
|
Deliberately NOT repaired. Two ``{key, value}`` items under a json body
|
|
have no single correct collapse: quoting each value breaks a variable whose
|
|
text is itself JSON (ESQ1-302's ``node3.text``), not quoting breaks plain
|
|
text with quotes or newlines. Only the author can say which was meant, so
|
|
the graph is rejected with the node named and regenerated instead.
|
|
"""
|
|
errors: list[tuple[str, str]] = []
|
|
for node in nodes:
|
|
if not isinstance(node, Mapping):
|
|
continue
|
|
data = node.get("data")
|
|
if not isinstance(data, Mapping) or data.get("type") != "http-request":
|
|
continue
|
|
body = data.get("body")
|
|
if not isinstance(body, Mapping):
|
|
continue
|
|
body_type = str(body.get("type") or "")
|
|
items = body.get("data")
|
|
if body_type not in _SINGLE_ITEM_BODY_TYPES:
|
|
continue
|
|
count = (1 if items else 0) if isinstance(items, str) else len(items) if isinstance(items, list) else 0
|
|
if count != 1:
|
|
errors.append(
|
|
(
|
|
str(node.get("id") or ""),
|
|
f"http-request {body_type} body must have exactly one item (one template "
|
|
f"holding the whole payload); node {node.get('id')!r} has {count} items",
|
|
)
|
|
)
|
|
return errors
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# parameter-extractor ``query``
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def normalize_parameter_extractor_queries(nodes: list[Any]) -> list[str]:
|
|
"""Unwrap a parameter-extractor ``query`` written as a list holding one
|
|
selector list.
|
|
|
|
graphon's ``ParameterExtractorNodeData.query`` is ``list[str]`` -- ONE
|
|
selector (``nodes/parameter_extractor/entities.py:112``), the same shape
|
|
a question-classifier's ``query_variable_selector`` already uses. A
|
|
``[["node2", "text"]]`` (an array of selector arrays) is always refused
|
|
at ``Graph.init``, so this only turns that engine-refused shape into the
|
|
one it evidently means.
|
|
|
|
Only the unambiguous case is healed: a list containing EXACTLY one inner
|
|
list of strings. Two or more inner lists (``[["a","b"],["c","d"]]``) have
|
|
no single correct collapse -- which one did the author mean? -- so that
|
|
is left exactly as written for the validator to reject and regenerate,
|
|
same as ``http_request_body_errors``' two-item json body. A correct flat
|
|
``["node2", "text"]`` and an empty ``[]`` are also left alone. Returns the
|
|
ids of the nodes that changed.
|
|
|
|
The inner list must also be a RESOLVABLE selector -- at least
|
|
``[node_id, variable]``. ``[[]]`` and ``[["node2"]]`` would unwrap to a
|
|
``list[str]`` the schema accepts, so ``Graph.init`` would stop refusing
|
|
them, but ``VariablePool.get`` returns ``None`` for any selector shorter
|
|
than 2 (``runtime/variable_pool.py:211``): the unwrap would trade a loud
|
|
refusal for a silent run-time break, which is the opposite of what a
|
|
normalizer is for.
|
|
"""
|
|
changed: list[str] = []
|
|
for node in nodes:
|
|
if not isinstance(node, Mapping):
|
|
continue
|
|
data = node.get("data")
|
|
if not isinstance(data, MutableMapping) or data.get("type") != "parameter-extractor":
|
|
continue
|
|
query = data.get("query")
|
|
if (
|
|
isinstance(query, list)
|
|
and len(query) == 1
|
|
and isinstance(query[0], list)
|
|
and len(query[0]) >= 2
|
|
and all(isinstance(item, str) for item in query[0])
|
|
):
|
|
data["query"] = query[0]
|
|
changed.append(str(node.get("id") or ""))
|
|
return changed
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Branch edge handles (if-else / question-classifier / human-input / fail-branch)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
# Names the planner uses for the arms of a two-arm if-else before the node
|
|
# builder has chosen a case id. Trusted as NAMES: an ELSE alias always means
|
|
# the implicit ``false`` arm, an IF alias the single declared case.
|
|
_ELSE_ALIASES = frozenset(
|
|
{"else", "otherwise", "default", "no", "not", "elsebranch", "else_branch", "false_branch", "falsebranch"}
|
|
)
|
|
_IF_ALIASES = frozenset(
|
|
{"if", "then", "yes", "true_branch", "truebranch", "if_branch", "ifbranch", "case", "case1", "case_1"}
|
|
)
|
|
_DEFAULT_HANDLES = frozenset({"", "source"})
|
|
# A handle shorter than this never claims a declared id by prefix ("no" must
|
|
# not swallow "nothing").
|
|
_MIN_STEM = 3
|
|
|
|
# Node types that route on named handles the node itself declares (if-else's
|
|
# case ids + the implicit "false" arm, question-classifier's class ids,
|
|
# human-input's action ids + the implicit "__timeout" arm) rather than the
|
|
# single default "source" handle every other node type uses.
|
|
_NAMED_BRANCH_TYPES = frozenset({"if-else", "question-classifier", "human-input"})
|
|
|
|
# human-input's implicit timeout arm. This is
|
|
# ``core.workflow.nodes.human_input.constants.TIMEOUT_HANDLE`` ("__timeout"),
|
|
# not imported: that package's __init__ pulls in graphon/pydantic entities,
|
|
# which this module's own contract (see the module docstring) keeps out --
|
|
# "no engine imports beyond the graph dict shape".
|
|
_HUMAN_INPUT_TIMEOUT_HANDLE = "__timeout"
|
|
# Names a planner might use for that implicit timeout arm.
|
|
_HUMAN_INPUT_TIMEOUT_ALIASES = frozenset(
|
|
{"timeout", "time_out", "timed_out", "timedout", "on_timeout", "expired", "expire", "expiry"}
|
|
)
|
|
|
|
# A fail-branch node's SUCCESS arm keeps routing on "source" (graphon's real
|
|
# default), never an invented "success" handle; these are the words a
|
|
# planner might use for it. Only added on a non-branch-type node -- a
|
|
# branch-type node (if-else / question-classifier / human-input) does not
|
|
# declare "source" at all, so mapping onto it would invent a handle nothing
|
|
# routes on.
|
|
_FAIL_BRANCH_SUCCESS_ALIASES = frozenset({"success", "ok", "next", "on_success"})
|
|
# ...and the words a planner might use for the failure arm. Valid on any
|
|
# fail-branch node regardless of type. Looked up by ``_canon_name`` like
|
|
# every other name, so ``fail_branch`` / ``fail-branch`` / ``Fail Branch``
|
|
# all hit the same key.
|
|
_FAIL_BRANCH_FAILURE_ALIASES = frozenset(
|
|
{
|
|
"fail",
|
|
"failure",
|
|
"failed",
|
|
"error",
|
|
"on_error",
|
|
"on_failure",
|
|
"exception",
|
|
"fail_branch",
|
|
"failbranch",
|
|
"error_branch",
|
|
"on_fail",
|
|
"fallback",
|
|
}
|
|
)
|
|
|
|
# Declared handles that exist because of the node's configuration, not
|
|
# because the author wired a branch: human-input's implicit "__timeout" arm
|
|
# exists on every human-input node regardless of what the author set up, and
|
|
# a fail-branch node's "fail-branch" arm exists because the author chose
|
|
# that error strategy, not because they connected anything to it. Both are
|
|
# routinely left unwired, so they must never count as "the one remaining
|
|
# slot" the fan-out (rule 2) / default-order (rule 3) heuristics assign to --
|
|
# otherwise they would (a) block resolution of the arm the author DID
|
|
# configure, or (b) silently steal an edge that belongs there instead. An
|
|
# edge already ON one of them still matches it exactly (the pass above, not
|
|
# this set); a named edge reaches either one through an explicit alias
|
|
# (_HUMAN_INPUT_TIMEOUT_ALIASES, _FAIL_BRANCH_*_ALIASES) instead.
|
|
_UNCOUNTED_UNUSED_HANDLES = frozenset({_HUMAN_INPUT_TIMEOUT_HANDLE, "fail-branch"})
|
|
|
|
|
|
def _canon(handle: Any) -> str:
|
|
"""Case-folds a HANDLE identifier for exact/stem comparison: alnum and
|
|
underscore are kept (both are meaningful in a handle id like
|
|
``"__timeout"`` or ``"true_branch"``), everything else -- hyphens,
|
|
spaces -- is dropped."""
|
|
return "".join(ch for ch in str(handle or "").lower() if ch.isalnum() or ch == "_")
|
|
|
|
|
|
def _canon_name(text: Any) -> str:
|
|
"""Case-folds a human-typed NAME (a class name, an action title, or an
|
|
alias word) for name-based lookups: unlike ``_canon``, an underscore is
|
|
folded away too, so ``"Tech Support"``, ``"tech_support"`` and
|
|
``"tech-support"`` all collapse to the same key. Exact/stem matching
|
|
against a real declared handle id stays on ``_canon`` -- ``"__timeout"``
|
|
must never collide with a literal edge handle ``"timeout"``."""
|
|
return "".join(ch for ch in str(text or "").lower() if ch.isalnum())
|
|
|
|
|
|
def _effective_handle(handle: Any) -> Any:
|
|
"""The handle graphon actually routes an edge on: a missing (or
|
|
explicitly ``None``) ``sourceHandle`` defaults to ``"source"``
|
|
(``edge_config.get("sourceHandle", "source")``, graphon
|
|
``graph/graph.py:131``). A graph that already relies on that default --
|
|
a fail-branch node's unkeyed success edges -- must come out identical,
|
|
not get re-homed or reported as if ``"source"`` were undeclared."""
|
|
return "source" if handle is None else handle
|
|
|
|
|
|
def _if_else_case_ids(data: Mapping[str, Any]) -> list[str]:
|
|
"""The case ids an if-else routes on, mirroring graphon's
|
|
``IfElseNodeData.iter_cases``: a node whose ``cases`` is absent or
|
|
``None`` is the legacy shape (top-level ``conditions``), which the engine
|
|
runs as ONE case with id ``"true"`` (and the canvas migrates to
|
|
``case_id: "true"``). An explicitly empty ``cases: []`` is not legacy --
|
|
it has no case at all and always takes the ELSE arm."""
|
|
cases = data.get("cases")
|
|
if cases is None:
|
|
return ["true"]
|
|
return [str(c["case_id"]) for c in cases or [] if isinstance(c, Mapping) and c.get("case_id")]
|
|
|
|
|
|
def declared_branch_handles(node: Mapping[str, Any]) -> list[str]:
|
|
"""The source handles ``node`` exposes, in declaration order; ``[]`` for a
|
|
node that has a single ``source`` handle.
|
|
|
|
An if-else declares its case ids (``"true"`` for the legacy shape with no
|
|
``cases`` -- see ``_if_else_case_ids``) plus the implicit ``"false"`` arm.
|
|
A node with ``error_strategy: "fail-branch"`` gains a ``"fail-branch"``
|
|
arm. Its success path stays on whatever handle the node already routes
|
|
on: graphon's ``NodeRunResult.edge_source_handle`` defaults to
|
|
``"source"``, so a plain node (http-request, code, ...) declares
|
|
``["source", "fail-branch"]`` -- not an invented ``"success"`` handle
|
|
nothing ever emits, which would strand its real ``"source"`` edge as
|
|
undeclared and skip it at run time. A branch-type node (if-else,
|
|
question-classifier, human-input) keeps routing on its own handles and
|
|
simply gains the extra ``"fail-branch"`` arm.
|
|
"""
|
|
data = node.get("data")
|
|
if not isinstance(data, Mapping):
|
|
return []
|
|
node_type = data.get("type")
|
|
handles: list[str] = []
|
|
if node_type == "if-else":
|
|
handles = [*_if_else_case_ids(data), "false"] # + the implicit ELSE arm
|
|
elif node_type == "question-classifier":
|
|
handles = [str(k["id"]) for k in data.get("classes") or [] if isinstance(k, Mapping) and k.get("id")]
|
|
elif node_type == "human-input":
|
|
handles = [str(a["id"]) for a in data.get("user_actions") or [] if isinstance(a, Mapping) and a.get("id")]
|
|
handles.append(_HUMAN_INPUT_TIMEOUT_HANDLE) # the implicit timeout arm
|
|
if data.get("error_strategy") == "fail-branch":
|
|
if node_type in _NAMED_BRANCH_TYPES:
|
|
handles.append("fail-branch")
|
|
else:
|
|
handles = ["source", "fail-branch"]
|
|
return handles
|
|
|
|
|
|
def _is_plain_fail_branch(node: Mapping[str, Any]) -> bool:
|
|
"""True for a non-branch-type node with ``error_strategy: "fail-branch"``:
|
|
it declares only ``["source", "fail-branch"]``, two handles with
|
|
opposite meanings. Eliminating between them by "the one arm left unused"
|
|
is too risky when a name is unrecognized -- only an explicit alias (or
|
|
the exact handle) may route onto either one."""
|
|
data = node.get("data")
|
|
if not isinstance(data, Mapping):
|
|
return False
|
|
return data.get("type") not in _NAMED_BRANCH_TYPES and data.get("error_strategy") == "fail-branch"
|
|
|
|
|
|
def _name_aliases(node: Mapping[str, Any], handles: list[str]) -> dict[str, str]:
|
|
"""canonical name -> declared handle, for the names a planner might use.
|
|
|
|
A canonical name that would resolve to more than one handle (e.g. two
|
|
question-classifier classes named "Support" and "support") is dropped
|
|
instead of picking one: an ambiguous alias must fail closed, not
|
|
silently pick a winner.
|
|
"""
|
|
data = node.get("data") or {}
|
|
node_type = data.get("type")
|
|
candidates: dict[str, set[str]] = {}
|
|
|
|
def add(name: Any, target: str) -> None:
|
|
candidates.setdefault(_canon_name(name), set()).add(target)
|
|
|
|
if node_type == "if-else":
|
|
for alias in _ELSE_ALIASES:
|
|
add(alias, "false")
|
|
# one case (the legacy shape counts as one) + the implicit ELSE: an IF alias is unambiguous
|
|
if len(_if_else_case_ids(data)) == 1:
|
|
for alias in _IF_ALIASES:
|
|
add(alias, handles[0])
|
|
elif node_type == "question-classifier":
|
|
for klass in data.get("classes") or []:
|
|
if isinstance(klass, Mapping) and klass.get("id") and klass.get("name"):
|
|
add(klass["name"], str(klass["id"]))
|
|
elif node_type == "human-input":
|
|
for action in data.get("user_actions") or []:
|
|
if isinstance(action, Mapping) and action.get("id"):
|
|
for key in ("title", "label", "name"):
|
|
if action.get(key):
|
|
add(action[key], str(action["id"]))
|
|
for alias in _HUMAN_INPUT_TIMEOUT_ALIASES:
|
|
add(alias, _HUMAN_INPUT_TIMEOUT_HANDLE)
|
|
|
|
if data.get("error_strategy") == "fail-branch":
|
|
if node_type not in _NAMED_BRANCH_TYPES: # only a plain node declares "source"
|
|
for alias in _FAIL_BRANCH_SUCCESS_ALIASES:
|
|
add(alias, "source")
|
|
for alias in _FAIL_BRANCH_FAILURE_ALIASES:
|
|
add(alias, "fail-branch")
|
|
|
|
return {name: next(iter(ids)) for name, ids in candidates.items() if len(ids) == 1}
|
|
|
|
|
|
def _match_handle(handle: str, handles: list[str], names: Mapping[str, str]) -> str | None:
|
|
"""A declared handle that ``handle`` unambiguously means, else ``None``.
|
|
|
|
An alias/name match and a stem match are cross-checked: if they disagree
|
|
(an alias word that also happens to be the unambiguous prefix of a real
|
|
declared handle -- the ELSE alias ``"default"`` against a case literally
|
|
named ``"default_case"``), that is not resolvable and fails closed
|
|
rather than silently picking one.
|
|
"""
|
|
canon = _canon(handle)
|
|
for declared in handles:
|
|
if _canon(declared) == canon:
|
|
return declared
|
|
alias_match = names.get(_canon_name(handle))
|
|
stem_match: str | None = None
|
|
if len(canon) >= _MIN_STEM:
|
|
stems = [
|
|
declared
|
|
for declared in handles
|
|
if len(_canon(declared)) >= _MIN_STEM
|
|
and (canon.startswith(_canon(declared)) or _canon(declared).startswith(canon))
|
|
]
|
|
if len(stems) == 1:
|
|
stem_match = stems[0]
|
|
if alias_match is not None and stem_match is not None:
|
|
return alias_match if alias_match == stem_match else None
|
|
return alias_match if alias_match is not None else stem_match
|
|
|
|
|
|
def repair_branch_edge_handles(nodes: list[Any], edges: list[Any]) -> list[dict[str, Any]]:
|
|
"""Re-home edges leaving a branch node onto the handles the node declares.
|
|
|
|
A missing or ``None`` ``sourceHandle`` is resolved the way graphon
|
|
resolves it at run time -- ``"source"`` -- before anything else: an
|
|
already-valid graph (a fail-branch node's unkeyed success edges) must
|
|
come out identical, never re-homed.
|
|
|
|
Three passes per branch node, each only when it is forced:
|
|
|
|
1. exact / alias / name / stem match (``else`` -> ``false``, a class name
|
|
-> its id, ``approved`` -> ``approve``, ``success``/``error`` on a
|
|
fail-branch node -> ``source``/``fail-branch``);
|
|
2. edges still unmatched, all carrying the SAME unknown handle, when
|
|
exactly one declared handle is unused -> that handle (one arm fanning
|
|
out under an invented name). Never applied to a plain (non-branch)
|
|
fail-branch node (see ``_is_plain_fail_branch``): with only "source"
|
|
and "fail-branch" declared, an unrecognized name is as likely to mean
|
|
one as the other, so only an explicit alias may resolve it. Nor to a
|
|
branch-type node whose "fail-branch" arm is still unwired: the
|
|
unknown name is as likely to be that failure arm as the one free
|
|
branch arm (ELSE / a class / an action);
|
|
3. edges on the default ``source`` handle, when there are at least as
|
|
many unused declared handles -> unused handles in declaration order
|
|
(the pre-existing behaviour).
|
|
|
|
"Unused" for (2) and (3) never counts a handle in
|
|
``_UNCOUNTED_UNUSED_HANDLES`` (human-input's implicit ``"__timeout"`` arm,
|
|
a fail-branch node's ``"fail-branch"`` arm): both exist because of the
|
|
node's configuration, not because the author wired a branch, and are
|
|
routinely left unwired, so they must not silently absorb an edge meant
|
|
for the one real arm that IS left unused, and must not block that arm's
|
|
resolution either.
|
|
|
|
Anything else -- two different unknown names, one unknown edge with two
|
|
free arms, a three-case node with invented names -- is left exactly as it
|
|
was and returned, so a caller can fail closed on it
|
|
(``undeclared_branch_handles`` still reports it afterwards). A wrong
|
|
guess would silently swap the IF and ELSE arms, or a success arm for a
|
|
failure arm; a visible rejection is better.
|
|
"""
|
|
unresolved: list[dict[str, Any]] = []
|
|
for node in nodes:
|
|
if not isinstance(node, Mapping):
|
|
continue
|
|
handles = declared_branch_handles(node)
|
|
if not handles:
|
|
continue
|
|
node_id = str(node.get("id") or "")
|
|
outgoing = [e for e in edges if isinstance(e, MutableMapping) and e.get("source") == node_id]
|
|
if not outgoing:
|
|
continue
|
|
names = _name_aliases(node, handles)
|
|
unknown: list[MutableMapping[str, Any]] = []
|
|
for edge in outgoing:
|
|
handle = edge.get("sourceHandle")
|
|
effective = _effective_handle(handle)
|
|
if effective in handles:
|
|
continue
|
|
if effective in _DEFAULT_HANDLES:
|
|
unknown.append(edge)
|
|
continue
|
|
matched = _match_handle(str(handle), handles, names)
|
|
if matched is None:
|
|
unknown.append(edge)
|
|
else:
|
|
edge["sourceHandle"] = matched
|
|
if not unknown:
|
|
continue
|
|
taken = {
|
|
_effective_handle(e.get("sourceHandle"))
|
|
for e in outgoing
|
|
if _effective_handle(e.get("sourceHandle")) in handles
|
|
}
|
|
unused = [h for h in handles if h not in taken and h not in _UNCOUNTED_UNUSED_HANDLES]
|
|
named = [
|
|
e
|
|
for e in unknown
|
|
if e.get("sourceHandle") not in (None, "") and e.get("sourceHandle") not in _DEFAULT_HANDLES
|
|
]
|
|
defaulted = [e for e in unknown if e not in named]
|
|
distinct_names = {str(e.get("sourceHandle")) for e in named}
|
|
# "fail-branch" never counts as unused (see _UNCOUNTED_UNUSED_HANDLES),
|
|
# but while it is unwired an unknown name may well mean it.
|
|
fail_branch_unwired = "fail-branch" in handles and "fail-branch" not in taken
|
|
if (
|
|
named
|
|
and not defaulted
|
|
and len(distinct_names) == 1
|
|
and len(unused) == 1
|
|
and not _is_plain_fail_branch(node)
|
|
and not fail_branch_unwired
|
|
):
|
|
for edge in named:
|
|
edge["sourceHandle"] = unused[0]
|
|
continue
|
|
if defaulted and not named and len(defaulted) <= len(unused):
|
|
for edge, handle in zip(defaulted, unused, strict=False):
|
|
edge["sourceHandle"] = handle
|
|
continue
|
|
for edge in unknown:
|
|
unresolved.append(
|
|
{
|
|
"node_id": node_id,
|
|
"target": str(edge.get("target") or ""),
|
|
"handle": edge.get("sourceHandle"),
|
|
"declared": list(handles),
|
|
}
|
|
)
|
|
return unresolved
|
|
|
|
|
|
def undeclared_branch_handles(nodes: list[Any], edges: list[Any]) -> list[dict[str, Any]]:
|
|
"""Every edge leaving a branch node on a handle the node does not declare
|
|
(including the default ``source``): the edge would hang off a handle that
|
|
does not exist and its arm would silently never run."""
|
|
bad: list[dict[str, Any]] = []
|
|
for node in nodes:
|
|
if not isinstance(node, Mapping):
|
|
continue
|
|
handles = declared_branch_handles(node)
|
|
if not handles:
|
|
continue
|
|
node_id = str(node.get("id") or "")
|
|
for edge in edges:
|
|
if not isinstance(edge, Mapping) or edge.get("source") != node_id:
|
|
continue
|
|
effective = _effective_handle(edge.get("sourceHandle"))
|
|
if effective in handles:
|
|
continue
|
|
bad.append(
|
|
{
|
|
"node_id": node_id,
|
|
"target": str(edge.get("target") or ""),
|
|
"handle": effective,
|
|
"declared": list(handles),
|
|
}
|
|
)
|
|
return bad
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# The deterministic heal set
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def heal_nodes_for_preflight(nodes: list[Any]) -> list[str]:
|
|
"""Run every deterministic node heal that must happen BEFORE a graph is
|
|
preflighted, and return the ids of the nodes that changed -- in normalizer
|
|
order, each id once (so a node two normalizers touched is reported once, at
|
|
the first normalizer that touched it, NOT in graph order).
|
|
|
|
This is the single definition of "the heals a graph gets before it is
|
|
validated". Three callers must agree on it or the Builder contradicts
|
|
itself: ``dify_port.apply_repair`` (the write chokepoint, which preflights
|
|
what it is about to write), ``graph_ops.filter_applicable`` (the dry run
|
|
that decides which intents are applicable, which preflights the same graph
|
|
a step earlier) and ``generator.runner._postprocess_graph`` (which hands its
|
|
graph to that same chokepoint). If any kept its own list, an intent could be
|
|
rejected as inapplicable for a defect the chokepoint would have healed -- or
|
|
accepted for one it would not.
|
|
|
|
Each member only turns a graph the engine REFUSES into one it ACCEPTS:
|
|
|
|
- ``normalize_conditions`` -- a condition operator written the ASCII way
|
|
(``>=`` -> ``≥``) and a condition value written as a JSON number
|
|
(ESQ1-303's ``"value": 60``);
|
|
- ``normalize_http_request_bodies`` -- the ``type`` ``BodyData`` cannot
|
|
default (ESQ1-302) and an ``authorization`` with no ``type``;
|
|
- ``normalize_parameter_extractor_queries`` -- a ``query`` written as an
|
|
array of selector arrays.
|
|
|
|
NOT included: ``derive_if_else_var_types``. ``varType`` is a frontend-only
|
|
operator hint the engine ignores, so it is not something a preflight can
|
|
turn on; the generator's postprocess calls it separately, right after this.
|
|
"""
|
|
changed: list[str] = []
|
|
for node_id in (
|
|
*normalize_conditions(nodes),
|
|
*normalize_http_request_bodies(nodes),
|
|
*normalize_parameter_extractor_queries(nodes),
|
|
):
|
|
if node_id not in changed:
|
|
changed.append(node_id)
|
|
return changed
|