Files
dify/api/services/dify_builder/generated_graph_completeness.py

148 lines
6.2 KiB
Python

"""Execution-path completeness for fresh, full Builder generation only.
A value selector describes data, not execution wiring. Refuse incomplete fresh
candidates so the existing bounded generation correction can repair their plan;
never infer edges, rewrite configurations, or constrain authored partial drafts.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from typing import TYPE_CHECKING, Any, Literal
if TYPE_CHECKING:
from core.dify_builder.models import MutationIntent
_CONTAINER_TYPES = frozenset({"iteration", "loop"})
_MARKER_TYPES = frozenset({"iteration-start", "loop-start"})
def generated_build_completeness_problems(
intents: Sequence[MutationIntent],
*,
mode: Literal["workflow", "advanced-chat"],
current_graph: Mapping[str, Any] | None,
) -> list[str]:
"""Check execution topology projected from the grounded applicable batch.
Fresh generation creates explicit IDs and connects them. The native create
owner sets data.type from node_type and parentId from parent_id; defaults
and config heals do not change those fields or connect endpoints. Project
exactly those owned effects rather than checking the pre-grounding response.
"""
nodes: list[dict[str, Any]] = []
edges: list[dict[str, Any]] = []
for intent in intents:
args = intent.args
if intent.op == "create_node":
nodes.append(
{
"id": args["node_id"],
"data": {"type": args["node_type"]},
"parentId": args.get("parent_id"),
}
)
elif intent.op == "connect":
edges.append({"source": args["from_node"], "target": args["to_node"]})
return generated_graph_completeness_problems(
{"nodes": nodes, "edges": edges}, mode=mode, current_graph=current_graph
)
def generated_graph_completeness_problems(
graph: Mapping[str, Any],
*,
mode: Literal["workflow", "advanced-chat"],
current_graph: Mapping[str, Any] | None,
) -> list[str]:
"""Require each top-level executable node on a Start-to-terminal path.
``current_graph`` is the caller's generation context, not model output:
any existing graph identifies refinement and receives no new restriction.
Containers are top-level execution units; their properly parented children
and synthetic start markers are not ordinary workflow-level entry/sink
nodes. Their internal topology remains the native container validator's
responsibility. Notes are canvas-only. Native ordinary node identities,
including legacy aggregator aliases, require no rewriting to participate.
"""
if current_graph is not None:
return []
nodes, edges = graph.get("nodes"), graph.get("edges")
if not isinstance(nodes, list) or not isinstance(edges, list):
return ["the full generated graph requires nodes and edges arrays"]
by_id: dict[str, Mapping[str, Any]] = {}
types: dict[str, str] = {}
for node in nodes:
if not isinstance(node, Mapping) or node.get("type") == "custom-note":
continue
data = node.get("data")
if not isinstance(data, Mapping) or not data.get("type"):
continue
node_id = node.get("id")
if not isinstance(node_id, str) or not node_id:
return ["an executable node in the full generated graph has no valid ID"]
if node_id in by_id:
return [f"the full generated graph has duplicate node ID {node_id!r}"]
by_id[node_id] = node
types[node_id] = str(data["type"])
top_level: set[str] = set()
for node_id, node in by_id.items():
parent = node.get("parentId")
if parent:
# Only actual, acyclic container ownership exempts an inner node.
seen = {node_id}
while parent:
if not isinstance(parent, str) or parent in seen or types.get(parent) not in _CONTAINER_TYPES:
return [f"node {node_id!r} has no valid container parent in the full generated graph"]
seen.add(parent)
parent = by_id[parent].get("parentId")
continue
if types[node_id] in _MARKER_TYPES:
return [f"container start marker {node_id!r} has no container parent"]
top_level.add(node_id)
starts = {node_id for node_id in top_level if types[node_id] == "start"}
terminal_type = "end" if mode == "workflow" else "answer"
terminals = {node_id for node_id in top_level if types[node_id] == terminal_type}
if len(starts) != 1:
return [f"the full generated graph requires exactly one top-level Start node; found {len(starts)}"]
if not terminals:
return [f"the full generated {mode} graph requires a top-level {terminal_type!r} terminal node"]
successors: dict[str, set[str]] = {}
predecessors: dict[str, set[str]] = {}
for edge in edges:
if not isinstance(edge, Mapping):
continue
source, target = edge.get("source"), edge.get("target")
if isinstance(source, str) and isinstance(target, str) and source in top_level and target in top_level:
successors.setdefault(source, set()).add(target)
predecessors.setdefault(target, set()).add(source)
def reachable(seeds: set[str], adjacency: Mapping[str, set[str]]) -> set[str]:
visited: set[str] = set()
pending = list(seeds)
while pending:
node_id = pending.pop()
if node_id in visited:
continue
visited.add(node_id)
pending.extend(adjacency.get(node_id, set()) - visited)
return visited
from_start = reachable(starts, successors)
to_terminal = reachable(terminals, predecessors)
problems = [
f"node {node_id!r} ({types[node_id]}) is unreachable from Start in the full generated graph; "
"regenerate the complete execution wiring"
for node_id in sorted(top_level - from_start)
]
problems.extend(
f"node {node_id!r} ({types[node_id]}) has no execution path to an {terminal_type!r} terminal; "
"regenerate the complete execution wiring instead of relying on value selectors"
for node_id in sorted(from_start - to_terminal)
)
return problems