mirror of
https://github.com/langgenius/dify.git
synced 2026-10-04 13:47:55 +08:00
feat(dify-builder): graph_ops.filter_applicable dry-run validation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
c73dcc7720
commit
f371732edc
2 files changed
+120
-14
No files matched your search
@@ -164,8 +164,7 @@ def apply_delete_node(graph: Graph, node_id: str) -> tuple[Graph, list[str]]:
|
||||
|
||||
new_graph["nodes"] = [n for n in nodes if n.get("id") != node_id]
|
||||
new_graph["edges"] = [
|
||||
e for e in new_graph.get("edges", [])
|
||||
if e.get("source") != node_id and e.get("target") != node_id
|
||||
e for e in new_graph.get("edges", []) if e.get("source") != node_id and e.get("target") != node_id
|
||||
]
|
||||
return new_graph, [node_id]
|
||||
|
||||
@@ -235,9 +234,7 @@ def apply_insert_between(
|
||||
old_target = edge.get("target")
|
||||
new_graph = copy.deepcopy(graph)
|
||||
edges = new_graph.get("edges", [])
|
||||
matched = next(
|
||||
(e for e in edges if e.get("source") == old_source and e.get("target") == old_target), None
|
||||
)
|
||||
matched = next((e for e in edges if e.get("source") == old_source and e.get("target") == old_target), None)
|
||||
if matched is None:
|
||||
raise ValueError(f"edge not found: {old_source} -> {old_target}")
|
||||
|
||||
@@ -287,16 +284,13 @@ def diff_graphs(before: Graph, after: Graph) -> tuple[list[str], str]:
|
||||
changes.extend(f"added node {node_id}" for node_id in added_nodes)
|
||||
changes.extend(f"removed node {node_id}" for node_id in removed_nodes)
|
||||
changes.extend(f"added {source} → {target}" for source, target, _sh, _th in added_edges)
|
||||
changes.extend(
|
||||
f"removed {source} → {target}" for source, target, _sh, _th in removed_edges
|
||||
)
|
||||
changes.extend(f"removed {source} → {target}" for source, target, _sh, _th in removed_edges)
|
||||
|
||||
for node_id in sorted(before_nodes.keys() & after_nodes.keys()):
|
||||
before_data = before_nodes[node_id].get("data", {})
|
||||
after_data = after_nodes[node_id].get("data", {})
|
||||
changed_keys = sorted(
|
||||
key for key in set(before_data) | set(after_data)
|
||||
if before_data.get(key) != after_data.get(key)
|
||||
key for key in set(before_data) | set(after_data) if before_data.get(key) != after_data.get(key)
|
||||
)
|
||||
changes.extend(f"{node_id}: {key} updated" for key in changed_keys)
|
||||
|
||||
@@ -311,10 +305,7 @@ def structural_fingerprint(graph: Graph) -> str:
|
||||
removing, or reconnecting a node changes it. Used by recovery (C-1) to
|
||||
tell a config-only hand-edit from a structural one. Order-independent
|
||||
(nodes and edges are sorted before hashing)."""
|
||||
nodes = sorted(
|
||||
(str(n.get("id", "")), str((n.get("data") or {}).get("type", "")))
|
||||
for n in graph.get("nodes", [])
|
||||
)
|
||||
nodes = sorted((str(n.get("id", "")), str((n.get("data") or {}).get("type", ""))) for n in graph.get("nodes", []))
|
||||
edges = sorted(
|
||||
(
|
||||
str(e.get("source", "")),
|
||||
@@ -332,3 +323,53 @@ def node_ids(graph: Graph) -> list[str]:
|
||||
"""The ids of every node in ``graph``, in document order (missing-id
|
||||
nodes skipped). Used by recovery's target-presence check."""
|
||||
return [str(n["id"]) for n in graph.get("nodes", []) if n.get("id") is not None]
|
||||
|
||||
|
||||
STRUCTURAL_OPS: frozenset[str] = frozenset({"create_node", "delete_node", "connect", "insert_between"})
|
||||
|
||||
_APPLY_FNS = {
|
||||
"set_node_config": apply_set_node_config,
|
||||
"create_node": apply_create_node,
|
||||
"delete_node": apply_delete_node,
|
||||
"connect": apply_connect,
|
||||
"insert_between": apply_insert_between,
|
||||
}
|
||||
|
||||
|
||||
def filter_applicable(
|
||||
graph: Graph,
|
||||
intents: list[MutationIntent],
|
||||
allowed_node_types: set[str] | None = None,
|
||||
) -> tuple[list[MutationIntent], list[tuple[MutationIntent, str]]]:
|
||||
"""Dry-run each intent through the real validate_intent_args + apply_* on a
|
||||
working deep copy, in order (so a connect sees a node an earlier create_node
|
||||
added). Returns (applicable, rejected) where each rejected entry is
|
||||
(intent, reason).
|
||||
|
||||
Catches every failure the live apply_repair would hit -- unknown op, missing
|
||||
required arg / extra arg key, dangling node/edge ref, duplicate id -- and
|
||||
additionally rejects a create_node / insert_between whose node_type is not in
|
||||
allowed_node_types (a check apply_* does not do). Applicable intents advance
|
||||
the working copy; a rejected intent does not.
|
||||
"""
|
||||
working = copy.deepcopy(graph)
|
||||
applicable: list[MutationIntent] = []
|
||||
rejected: list[tuple[MutationIntent, str]] = []
|
||||
for intent in intents:
|
||||
apply_fn = _APPLY_FNS.get(intent.op)
|
||||
if apply_fn is None:
|
||||
rejected.append((intent, f"unknown mutation op: {intent.op!r}"))
|
||||
continue
|
||||
if allowed_node_types is not None and intent.op in ("create_node", "insert_between"):
|
||||
node_type = intent.args.get("node_type")
|
||||
if node_type not in allowed_node_types:
|
||||
rejected.append((intent, f"node_type not allowed: {node_type!r}"))
|
||||
continue
|
||||
try:
|
||||
validate_intent_args(intent)
|
||||
working, _changed = apply_fn(working, **intent.args)
|
||||
except (ValueError, TypeError) as exc:
|
||||
rejected.append((intent, str(exc)))
|
||||
continue
|
||||
applicable.append(intent)
|
||||
return applicable, rejected
|
||||
@@ -12,6 +12,7 @@ import copy
|
||||
import pytest
|
||||
|
||||
from core.dify_builder.models import MutationIntent
|
||||
from services.dify_builder import graph_ops
|
||||
from services.dify_builder.graph_ops import (
|
||||
MUTATION_ARG_KEYS,
|
||||
apply_connect,
|
||||
@@ -475,3 +476,67 @@ def test_node_ids():
|
||||
g = {"nodes": [{"id": "a", "data": {"type": "llm"}}, {"id": "b", "data": {"type": "end"}}], "edges": []}
|
||||
assert node_ids(g) == ["a", "b"]
|
||||
assert node_ids({}) == []
|
||||
|
||||
|
||||
# ---- filter_applicable -------------------------------------------------------
|
||||
|
||||
|
||||
_G = {
|
||||
"nodes": [
|
||||
{"id": "a", "type": "custom", "data": {"type": "code", "title": "Code"}},
|
||||
{"id": "b", "type": "custom", "data": {"type": "end", "title": "End"}},
|
||||
],
|
||||
"edges": [{"id": "a-b", "source": "a", "target": "b"}],
|
||||
}
|
||||
|
||||
|
||||
def test_filter_applicable_all_valid():
|
||||
intents = [MutationIntent(op="set_node_config", args={"node_id": "a", "path": "code", "value": "x"})]
|
||||
ok, bad = graph_ops.filter_applicable(_G, intents)
|
||||
assert ok == intents
|
||||
assert bad == []
|
||||
|
||||
|
||||
def test_filter_rejects_unknown_op():
|
||||
intents = [MutationIntent(op="frobnicate", args={})]
|
||||
ok, bad = graph_ops.filter_applicable(_G, intents)
|
||||
assert ok == []
|
||||
assert "unknown mutation op" in bad[0][1]
|
||||
|
||||
|
||||
def test_filter_rejects_missing_required_arg():
|
||||
intents = [MutationIntent(op="set_node_config", args={"node_id": "a"})]
|
||||
ok, bad = graph_ops.filter_applicable(_G, intents)
|
||||
assert ok == []
|
||||
assert "missing required arg" in bad[0][1]
|
||||
|
||||
|
||||
def test_filter_rejects_extra_arg_key():
|
||||
intents = [MutationIntent(op="delete_node", args={"node_id": "a", "bogus": 1})]
|
||||
ok, bad = graph_ops.filter_applicable(_G, intents)
|
||||
assert ok == []
|
||||
assert "bogus" in bad[0][1]
|
||||
|
||||
|
||||
def test_filter_rejects_dangling_ref():
|
||||
intents = [MutationIntent(op="connect", args={"from_node": "a", "to_node": "zzz"})]
|
||||
ok, bad = graph_ops.filter_applicable(_G, intents)
|
||||
assert ok == []
|
||||
assert "node not found" in bad[0][1]
|
||||
|
||||
|
||||
def test_filter_rejects_unknown_node_type():
|
||||
intents = [MutationIntent(op="create_node", args={"node_type": "made-up", "config": {}})]
|
||||
ok, bad = graph_ops.filter_applicable(_G, intents, allowed_node_types={"llm", "end"})
|
||||
assert ok == []
|
||||
assert "node_type not allowed" in bad[0][1]
|
||||
|
||||
|
||||
def test_filter_create_then_connect_ordering():
|
||||
intents = [
|
||||
MutationIntent(op="create_node", args={"node_type": "llm", "config": {}, "node_id": "c"}),
|
||||
MutationIntent(op="connect", args={"from_node": "c", "to_node": "b"}),
|
||||
]
|
||||
ok, bad = graph_ops.filter_applicable(_G, intents, allowed_node_types={"llm", "end"})
|
||||
assert ok == intents # the connect sees the node the prior create added
|
||||
assert bad == []
|
||||
Reference in new issue
Block a user