refactor: add graph utility tests and refactor sorting methods (#5538)
* refactor: turn sorting methods into functions in a separate module - Added `layered_topological_sort` function to perform layered topological sorting of graph vertices, accommodating cycles and input vertex checks. - Introduced `refine_layers` function to ensure proper dependency ordering among vertices. - Implemented helper functions for sorting layers by dependency and filtering vertices based on predecessors. - Enhanced utility functions to support better graph traversal and layer management. This update improves the graph processing capabilities, allowing for more efficient handling of complex graph structures. * feat(tests): enhance graph utility tests with cycle detection and sorting functionality - Added a new fixture `graph_with_loop` to simulate a graph containing cycles for testing purposes. - Improved the `test_large_graph_efficiency` to validate cycle detection in large graphs. - Introduced multiple tests for sorting vertices in graphs with cycles, ensuring correct order and handling of input vertices. - Enhanced assertions to provide clearer error messages for failed tests, improving debugging experience. These changes strengthen the testing framework for graph utilities, ensuring robust handling of complex graph structures. * refactor(graph): remove unused parent_node_map from Graph class initialization - Eliminated the `parent_node_map` parameter from the Graph class constructor, streamlining the graph initialization process. - This change enhances code clarity and reduces unnecessary complexity in graph management. This update contributes to cleaner and more maintainable graph-related code. * refactor(graph): optimize dependency sorting and vertex filtering - Improved the `_max_dependency_index` function by utilizing `index_map.get()` for cleaner code and better handling of missing successors. - Enhanced the `_sort_single_layer_by_dependency` function with a caching mechanism to avoid redundant calculations, improving performance during vertex sorting. - Updated `filter_vertices_up_to_vertex` to use a set for `vertices_ids`, optimizing membership checks and enhancing efficiency in vertex filtering. These changes contribute to more efficient graph processing and improved code readability. * chore: remove unused 'parent_node_map' parameter * [autofix.ci] apply automated fixes * fix: replace old method call with a new func * test: enhance assertions for file existence in webhook tests * refactor(graph): enhance component ID retrieval and chat input sorting - Updated `find_start_component_id` to accept an optional `is_webhook` parameter, allowing for dynamic priority input selection based on the flow type. - Improved `sort_chat_inputs_first` to handle chat input positioning more efficiently, ensuring only one chat input exists and adjusting its position within the layers as needed. - These changes enhance the flexibility and efficiency of graph processing, particularly for webhook flows. * test(graph): update assertions in sort_chat_inputs_first test for accuracy - Modified assertions in the `test_chat_inputs_at_start` function to reflect the correct expected output of the `sort_chat_inputs_first` utility. - Adjusted the expected length and order of the result to ensure accurate validation of chat input sorting functionality. These changes enhance the reliability of the test suite for graph utilities, ensuring that the sorting logic is correctly validated. * test(chat): update assertion in consume_and_assert_stream for accurate ID validation - Modified the assertion in the `consume_and_assert_stream` function to include an additional expected ID, ensuring the test accurately reflects the current output of the chat endpoint. - This change enhances the reliability of the test suite by validating the correct behavior of the chat input sorting functionality. * test(endpoints): update assertion in test_get_vertices for accurate ID validation - Modified the assertion in the `test_get_vertices` function to include an additional expected ID, "Webhook", alongside "ChatInput". - This change ensures the test accurately reflects the current output of the endpoint, enhancing the reliability of the test suite for endpoint functionality. --------- Co-authored-by: italojohnny <italojohnnydosanjos@gmail.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
parent
319380462e
commit
9c23759c7d
8 changed files with 1940 additions and 946 deletions
|
|
@ -26,9 +26,9 @@ from langflow.graph.graph.utils import (
|
|||
find_all_cycle_edges,
|
||||
find_cycle_vertices,
|
||||
find_start_component_id,
|
||||
get_sorted_vertices,
|
||||
process_flow,
|
||||
should_continue,
|
||||
sort_up_to_vertex,
|
||||
)
|
||||
from langflow.graph.schema import InterfaceComponentTypes, RunOutputs
|
||||
from langflow.graph.vertex.base import Vertex, VertexStates
|
||||
|
|
@ -1872,168 +1872,25 @@ class Graph:
|
|||
f"{edges_repr}"
|
||||
)
|
||||
|
||||
def layered_topological_sort(
|
||||
self,
|
||||
vertices: list[Vertex],
|
||||
*,
|
||||
filter_graphs: bool = False,
|
||||
) -> list[list[str]]:
|
||||
"""Performs a layered topological sort of the vertices in the graph."""
|
||||
vertices_ids = {vertex.id for vertex in vertices}
|
||||
# Queue for vertices with no incoming edges
|
||||
in_degree_map = self.in_degree_map.copy()
|
||||
if self.is_cyclic and all(in_degree_map.values()):
|
||||
# This means we have a cycle because all vertex have in_degree_map > 0
|
||||
# because of this we set the queue to start on the ._start if it exists
|
||||
if self._start is not None:
|
||||
queue = deque([self._start._id])
|
||||
else:
|
||||
# Find the chat input component
|
||||
chat_input = find_start_component_id(vertices_ids)
|
||||
if chat_input is None:
|
||||
msg = "No input component found and no start component provided"
|
||||
raise ValueError(msg)
|
||||
queue = deque([chat_input])
|
||||
else:
|
||||
queue = deque(
|
||||
vertex.id
|
||||
for vertex in vertices
|
||||
# if filter_graphs then only vertex.is_input will be considered
|
||||
if in_degree_map[vertex.id] == 0 and (not filter_graphs or vertex.is_input)
|
||||
)
|
||||
layers: list[list[str]] = []
|
||||
visited = set(queue)
|
||||
def get_vertex_predecessors_ids(self, vertex_id: str) -> list[str]:
|
||||
"""Get the predecessor IDs of a vertex."""
|
||||
return [v.id for v in self.get_predecessors(self.get_vertex(vertex_id))]
|
||||
|
||||
current_layer = 0
|
||||
while queue:
|
||||
layers.append([]) # Start a new layer
|
||||
layer_size = len(queue)
|
||||
for _ in range(layer_size):
|
||||
vertex_id = queue.popleft()
|
||||
visited.add(vertex_id)
|
||||
def get_vertex_successors_ids(self, vertex_id: str) -> list[str]:
|
||||
"""Get the successor IDs of a vertex."""
|
||||
return [v.id for v in self.get_vertex(vertex_id).successors]
|
||||
|
||||
layers[current_layer].append(vertex_id)
|
||||
for neighbor in self.successor_map[vertex_id]:
|
||||
# only vertices in `vertices_ids` should be considered
|
||||
# because vertices by have been filtered out
|
||||
# in a previous step. All dependencies of theirs
|
||||
# will be built automatically if required
|
||||
if neighbor not in vertices_ids:
|
||||
continue
|
||||
def get_vertex_input_status(self, vertex_id: str) -> bool:
|
||||
"""Check if a vertex is an input vertex."""
|
||||
return self.get_vertex(vertex_id).is_input
|
||||
|
||||
in_degree_map[neighbor] -= 1 # 'remove' edge
|
||||
if in_degree_map[neighbor] == 0 and neighbor not in visited:
|
||||
queue.append(neighbor)
|
||||
def get_parent_map(self) -> dict[str, str | None]:
|
||||
"""Get the parent node map for all vertices."""
|
||||
return {vertex.id: vertex.parent_node_id for vertex in self.vertices}
|
||||
|
||||
# if > 0 it might mean not all predecessors have added to the queue
|
||||
# so we should process the neighbors predecessors
|
||||
elif in_degree_map[neighbor] > 0:
|
||||
for predecessor in self.predecessor_map[neighbor]:
|
||||
if predecessor not in queue and predecessor not in visited:
|
||||
queue.append(predecessor)
|
||||
|
||||
current_layer += 1 # Next layer
|
||||
return self.refine_layers(layers)
|
||||
|
||||
def refine_layers(self, initial_layers):
|
||||
# Map each vertex to its current layer
|
||||
vertex_to_layer = {}
|
||||
for layer_index, layer in enumerate(initial_layers):
|
||||
for vertex in layer:
|
||||
vertex_to_layer[vertex] = layer_index
|
||||
|
||||
# Build the adjacency list for reverse lookup (dependencies)
|
||||
|
||||
refined_layers = [[] for _ in initial_layers] # Start with empty layers
|
||||
new_layer_index_map = defaultdict(int)
|
||||
|
||||
# Map each vertex to its new layer index
|
||||
# by finding the lowest layer index of its dependencies
|
||||
# and subtracting 1
|
||||
# If a vertex has no dependencies, it will be placed in the first layer
|
||||
# If a vertex has dependencies, it will be placed in the lowest layer index of its dependencies
|
||||
# minus 1
|
||||
for vertex_id, deps in self.successor_map.items():
|
||||
indexes = [vertex_to_layer[dep] for dep in deps if dep in vertex_to_layer]
|
||||
new_layer_index = max(min(indexes, default=0) - 1, 0)
|
||||
new_layer_index_map[vertex_id] = new_layer_index
|
||||
|
||||
for layer_index, layer in enumerate(initial_layers):
|
||||
for vertex_id in layer:
|
||||
# Place the vertex in the highest possible layer where its dependencies are met
|
||||
new_layer_index = new_layer_index_map[vertex_id]
|
||||
if new_layer_index > layer_index:
|
||||
refined_layers[new_layer_index].append(vertex_id)
|
||||
vertex_to_layer[vertex_id] = new_layer_index
|
||||
else:
|
||||
refined_layers[layer_index].append(vertex_id)
|
||||
|
||||
# Remove empty layers if any
|
||||
return [layer for layer in refined_layers if layer]
|
||||
|
||||
def sort_chat_inputs_first(self, vertices_layers: list[list[str]]) -> list[list[str]]:
|
||||
chat_inputs = []
|
||||
for layer in vertices_layers:
|
||||
for vertex_id in layer:
|
||||
if "ChatInput" in vertex_id and self.get_predecessors(self.get_vertex(vertex_id)):
|
||||
return vertices_layers
|
||||
if "ChatInput" in vertex_id:
|
||||
chat_inputs.append(vertex_id)
|
||||
|
||||
if not chat_inputs:
|
||||
return vertices_layers
|
||||
|
||||
chat_inputs_first = []
|
||||
for layer in vertices_layers:
|
||||
layer_chat_inputs_first = [vertex_id for vertex_id in layer if "ChatInput" in vertex_id]
|
||||
chat_inputs_first.extend(layer_chat_inputs_first)
|
||||
layer[:] = [v for v in layer if v not in layer_chat_inputs_first]
|
||||
|
||||
return [chat_inputs_first, *vertices_layers]
|
||||
|
||||
def sort_layer_by_dependency(self, vertices_layers: list[list[str]]) -> list[list[str]]:
|
||||
"""Sorts the vertices in each layer by dependency, ensuring no vertex depends on a subsequent vertex."""
|
||||
sorted_layers = []
|
||||
|
||||
for layer in vertices_layers:
|
||||
sorted_layer = self._sort_single_layer_by_dependency(layer)
|
||||
sorted_layers.append(sorted_layer)
|
||||
|
||||
return sorted_layers
|
||||
|
||||
def _sort_single_layer_by_dependency(self, layer: list[str]) -> list[str]:
|
||||
"""Sorts a single layer by dependency using a stable sorting method."""
|
||||
# Build a map of each vertex to its index in the layer for quick lookup.
|
||||
index_map = {vertex: index for index, vertex in enumerate(layer)}
|
||||
# Create a sorted copy of the layer based on dependency order.
|
||||
return sorted(layer, key=lambda vertex: self._max_dependency_index(vertex, index_map), reverse=True)
|
||||
|
||||
def _max_dependency_index(self, vertex_id: str, index_map: dict[str, int]) -> int:
|
||||
"""Finds the highest index a given vertex's dependencies occupy in the same layer."""
|
||||
vertex = self.get_vertex(vertex_id)
|
||||
max_index = -1
|
||||
for successor in vertex.successors: # Assuming vertex.successors is a list of successor vertex identifiers.
|
||||
if successor.id in index_map:
|
||||
max_index = max(max_index, index_map[successor.id])
|
||||
return max_index
|
||||
|
||||
def __to_dict(self) -> dict[str, dict[str, list[str]]]:
|
||||
"""Converts the graph to a dictionary."""
|
||||
result: dict = {}
|
||||
for vertex in self.vertices:
|
||||
vertex_id = vertex.id
|
||||
sucessors = [i.id for i in self.get_all_successors(vertex)]
|
||||
predecessors = [i.id for i in self.get_predecessors(vertex)]
|
||||
result |= {vertex_id: {"successors": sucessors, "predecessors": predecessors}}
|
||||
return result
|
||||
|
||||
def __filter_vertices(self, vertex_id: str, *, is_start: bool = False):
|
||||
dictionaryized_graph = self.__to_dict()
|
||||
parent_node_map = {vertex.id: vertex.parent_node_id for vertex in self.vertices}
|
||||
vertex_ids = sort_up_to_vertex(
|
||||
graph=dictionaryized_graph, vertex_id=vertex_id, parent_node_map=parent_node_map, is_start=is_start
|
||||
)
|
||||
return [self.get_vertex(vertex_id) for vertex_id in vertex_ids]
|
||||
def get_vertex_ids(self) -> list[str]:
|
||||
"""Get all vertex IDs in the graph."""
|
||||
return [vertex.id for vertex in self.vertices]
|
||||
|
||||
def sort_vertices(
|
||||
self,
|
||||
|
|
@ -2042,39 +1899,27 @@ class Graph:
|
|||
) -> list[str]:
|
||||
"""Sorts the vertices in the graph."""
|
||||
self.mark_all_vertices("ACTIVE")
|
||||
if stop_component_id in self.cycle_vertices:
|
||||
# Make the stop into a start because we are in a cycle and
|
||||
# we cannot know where is the input or output
|
||||
start_component_id = stop_component_id
|
||||
stop_component_id = None
|
||||
if stop_component_id is not None:
|
||||
self.stop_vertex = stop_component_id
|
||||
vertices = self.__filter_vertices(stop_component_id)
|
||||
|
||||
elif start_component_id:
|
||||
vertices = self.__filter_vertices(start_component_id, is_start=True)
|
||||
first_layer, remaining_layers = get_sorted_vertices(
|
||||
vertices_ids=self.get_vertex_ids(),
|
||||
cycle_vertices=self.cycle_vertices,
|
||||
stop_component_id=stop_component_id,
|
||||
start_component_id=start_component_id,
|
||||
graph_dict=self.__to_dict(),
|
||||
in_degree_map=self.in_degree_map,
|
||||
successor_map=self.successor_map,
|
||||
predecessor_map=self.predecessor_map,
|
||||
is_input_vertex=self.get_vertex_input_status,
|
||||
get_vertex_predecessors=self.get_vertex_predecessors_ids,
|
||||
get_vertex_successors=self.get_vertex_successors_ids,
|
||||
is_cyclic=self.is_cyclic,
|
||||
)
|
||||
|
||||
else:
|
||||
vertices = self.vertices
|
||||
# without component_id we are probably running in the chat
|
||||
# so we want to pick only graphs that start with ChatInput or
|
||||
# TextInput
|
||||
|
||||
vertices_layers = self.layered_topological_sort(vertices)
|
||||
vertices_layers = self.sort_by_avg_build_time(vertices_layers)
|
||||
# Sort the chat inputs first to speed up sending the User message to the UI
|
||||
vertices_layers = self.sort_chat_inputs_first(vertices_layers)
|
||||
# Now we should sort each layer in a way that we make sure
|
||||
# vertex V does not depend on vertex V+1
|
||||
vertices_layers = self.sort_layer_by_dependency(vertices_layers)
|
||||
self.increment_run_count()
|
||||
self._sorted_vertices_layers = vertices_layers
|
||||
first_layer = vertices_layers[0]
|
||||
# save the only the rest
|
||||
self.vertices_layers = vertices_layers[1:]
|
||||
self.vertices_to_run = set(chain.from_iterable(vertices_layers))
|
||||
self._sorted_vertices_layers = [first_layer, *remaining_layers]
|
||||
self.vertices_layers = remaining_layers
|
||||
self.vertices_to_run = set(chain.from_iterable([first_layer, *remaining_layers]))
|
||||
self.build_run_map()
|
||||
# Return just the first layer
|
||||
self._first_layer = first_layer
|
||||
return first_layer
|
||||
|
||||
|
|
@ -2195,3 +2040,13 @@ class Graph:
|
|||
predecessor_map[edge.target_id].append(edge.source_id)
|
||||
successor_map[edge.source_id].append(edge.target_id)
|
||||
return predecessor_map, successor_map
|
||||
|
||||
def __to_dict(self) -> dict[str, dict[str, list[str]]]:
|
||||
"""Converts the graph to a dictionary."""
|
||||
result: dict = {}
|
||||
for vertex in self.vertices:
|
||||
vertex_id = vertex.id
|
||||
sucessors = [i.id for i in self.get_all_successors(vertex)]
|
||||
predecessors = [i.id for i in self.get_predecessors(vertex)]
|
||||
result |= {vertex_id: {"successors": sucessors, "predecessors": predecessors}}
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -1,21 +1,29 @@
|
|||
import copy
|
||||
from collections import defaultdict, deque
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
import networkx as nx
|
||||
|
||||
PRIORITY_LIST_OF_INPUTS = ["webhook", "chat"]
|
||||
MAX_CYCLE_APPEARANCES = 2
|
||||
|
||||
|
||||
def find_start_component_id(vertices):
|
||||
def find_start_component_id(vertices, *, is_webhook: bool = False):
|
||||
"""Finds the component ID from a list of vertices based on a priority list of input types.
|
||||
|
||||
Args:
|
||||
vertices (list): A list of vertex IDs.
|
||||
is_webhook (bool, optional): Whether the flow is being run as a webhook. Defaults to False.
|
||||
|
||||
Returns:
|
||||
str or None: The component ID that matches the highest priority input type, or None if no match is found.
|
||||
"""
|
||||
for input_type_str in PRIORITY_LIST_OF_INPUTS:
|
||||
# Set priority list based on whether this is a webhook flow
|
||||
priority_inputs = ["webhook"] if is_webhook else PRIORITY_LIST_OF_INPUTS
|
||||
|
||||
# Check input types in priority order
|
||||
for input_type_str in priority_inputs:
|
||||
component_id = next((vertex_id for vertex_id in vertices if input_type_str in vertex_id.lower()), None)
|
||||
if component_id:
|
||||
return component_id
|
||||
|
|
@ -440,3 +448,484 @@ def find_cycle_vertices(edges):
|
|||
cycle_vertices.update(component)
|
||||
|
||||
return sorted(cycle_vertices)
|
||||
|
||||
|
||||
def layered_topological_sort(
|
||||
vertices_ids: set[str],
|
||||
in_degree_map: dict[str, int],
|
||||
successor_map: dict[str, list[str]],
|
||||
predecessor_map: dict[str, list[str]],
|
||||
start_id: str | None = None,
|
||||
cycle_vertices: set[str] | None = None,
|
||||
is_input_vertex: Callable[[str], bool] | None = None,
|
||||
*,
|
||||
is_cyclic: bool = False,
|
||||
) -> list[list[str]]:
|
||||
"""Performs a layered topological sort of the vertices in the graph.
|
||||
|
||||
Args:
|
||||
vertices_ids: Set of vertex IDs to sort
|
||||
in_degree_map: Map of vertex IDs to their in-degree
|
||||
successor_map: Map of vertex IDs to their successors
|
||||
predecessor_map: Map of vertex IDs to their predecessors
|
||||
is_cyclic: Whether the graph is cyclic
|
||||
start_id: ID of the start vertex (if any)
|
||||
cycle_vertices: Set of vertices that form a cycle
|
||||
is_input_vertex: Function to check if a vertex is an input vertex
|
||||
|
||||
Returns:
|
||||
List of layers, where each layer is a list of vertex IDs
|
||||
"""
|
||||
# Queue for vertices with no incoming edges
|
||||
cycle_vertices = cycle_vertices or set()
|
||||
in_degree_map = in_degree_map.copy()
|
||||
|
||||
if is_cyclic and all(in_degree_map.values()):
|
||||
# This means we have a cycle because all vertex have in_degree_map > 0
|
||||
# because of this we set the queue to start on the start_id if it exists
|
||||
if start_id is not None:
|
||||
queue = deque([start_id])
|
||||
# Reset in_degree for start_id to allow cycle traversal
|
||||
in_degree_map[start_id] = 0
|
||||
else:
|
||||
# Find the chat input component
|
||||
chat_input = find_start_component_id(vertices_ids)
|
||||
if chat_input is None:
|
||||
# If no input component is found, start with any vertex
|
||||
queue = deque([next(iter(vertices_ids))])
|
||||
in_degree_map[next(iter(vertices_ids))] = 0
|
||||
else:
|
||||
queue = deque([chat_input])
|
||||
# Reset in_degree for chat_input to allow cycle traversal
|
||||
in_degree_map[chat_input] = 0
|
||||
else:
|
||||
# Start with vertices that have no incoming edges or are input vertices
|
||||
queue = deque(
|
||||
vertex_id
|
||||
for vertex_id in vertices_ids
|
||||
if in_degree_map[vertex_id] == 0 or (is_input_vertex and is_input_vertex(vertex_id))
|
||||
)
|
||||
|
||||
layers: list[list[str]] = []
|
||||
visited = set()
|
||||
cycle_counts = {vertex: 0 for vertex in vertices_ids}
|
||||
current_layer = 0
|
||||
|
||||
# Process the first layer separately to avoid duplicates
|
||||
if queue:
|
||||
layers.append([]) # Start the first layer
|
||||
first_layer_vertices = set()
|
||||
layer_size = len(queue)
|
||||
for _ in range(layer_size):
|
||||
vertex_id = queue.popleft()
|
||||
if vertex_id not in first_layer_vertices:
|
||||
first_layer_vertices.add(vertex_id)
|
||||
visited.add(vertex_id)
|
||||
cycle_counts[vertex_id] += 1
|
||||
layers[current_layer].append(vertex_id)
|
||||
|
||||
for neighbor in successor_map[vertex_id]:
|
||||
# only vertices in `vertices_ids` should be considered
|
||||
# because vertices by have been filtered out
|
||||
# in a previous step. All dependencies of theirs
|
||||
# will be built automatically if required
|
||||
if neighbor not in vertices_ids:
|
||||
continue
|
||||
|
||||
in_degree_map[neighbor] -= 1 # 'remove' edge
|
||||
if in_degree_map[neighbor] == 0:
|
||||
queue.append(neighbor)
|
||||
|
||||
# if > 0 it might mean not all predecessors have added to the queue
|
||||
# so we should process the neighbors predecessors
|
||||
elif in_degree_map[neighbor] > 0:
|
||||
for predecessor in predecessor_map[neighbor]:
|
||||
if (
|
||||
predecessor not in queue
|
||||
and predecessor not in first_layer_vertices
|
||||
and (in_degree_map[predecessor] == 0 or predecessor in cycle_vertices)
|
||||
):
|
||||
queue.append(predecessor)
|
||||
|
||||
current_layer += 1 # Next layer
|
||||
|
||||
# Process remaining layers normally, allowing cycle vertices to appear multiple times
|
||||
while queue:
|
||||
layers.append([]) # Start a new layer
|
||||
layer_size = len(queue)
|
||||
for _ in range(layer_size):
|
||||
vertex_id = queue.popleft()
|
||||
if vertex_id not in visited or (is_cyclic and cycle_counts[vertex_id] < MAX_CYCLE_APPEARANCES):
|
||||
if vertex_id not in visited:
|
||||
visited.add(vertex_id)
|
||||
cycle_counts[vertex_id] += 1
|
||||
layers[current_layer].append(vertex_id)
|
||||
|
||||
for neighbor in successor_map[vertex_id]:
|
||||
# only vertices in `vertices_ids` should be considered
|
||||
# because vertices by have been filtered out
|
||||
# in a previous step. All dependencies of theirs
|
||||
# will be built automatically if required
|
||||
if neighbor not in vertices_ids:
|
||||
continue
|
||||
|
||||
in_degree_map[neighbor] -= 1 # 'remove' edge
|
||||
if in_degree_map[neighbor] == 0 and neighbor not in visited:
|
||||
queue.append(neighbor)
|
||||
# # If this is a cycle vertex, reset its in_degree to allow it to appear again
|
||||
# if neighbor in cycle_vertices and neighbor in visited:
|
||||
# in_degree_map[neighbor] = len(predecessor_map[neighbor])
|
||||
|
||||
# if > 0 it might mean not all predecessors have added to the queue
|
||||
# so we should process the neighbors predecessors
|
||||
elif in_degree_map[neighbor] > 0:
|
||||
for predecessor in predecessor_map[neighbor]:
|
||||
if predecessor not in queue and (
|
||||
predecessor not in visited
|
||||
or (is_cyclic and cycle_counts[predecessor] < MAX_CYCLE_APPEARANCES)
|
||||
):
|
||||
queue.append(predecessor)
|
||||
|
||||
current_layer += 1 # Next layer
|
||||
|
||||
# Remove empty layers
|
||||
return [layer for layer in layers if layer]
|
||||
|
||||
|
||||
def refine_layers(
|
||||
initial_layers: list[list[str]],
|
||||
successor_map: dict[str, list[str]],
|
||||
) -> list[list[str]]:
|
||||
"""Refines the layers of vertices to ensure proper dependency ordering.
|
||||
|
||||
Args:
|
||||
initial_layers: Initial layers of vertices
|
||||
successor_map: Map of vertex IDs to their successors
|
||||
|
||||
Returns:
|
||||
Refined layers with proper dependency ordering
|
||||
"""
|
||||
# Map each vertex to its current layer
|
||||
vertex_to_layer: dict[str, int] = {}
|
||||
for layer_index, layer in enumerate(initial_layers):
|
||||
for vertex in layer:
|
||||
vertex_to_layer[vertex] = layer_index
|
||||
|
||||
refined_layers: list[list[str]] = [[] for _ in initial_layers] # Start with empty layers
|
||||
new_layer_index_map = defaultdict(int)
|
||||
|
||||
# Map each vertex to its new layer index
|
||||
# by finding the lowest layer index of its dependencies
|
||||
# and subtracting 1
|
||||
# If a vertex has no dependencies, it will be placed in the first layer
|
||||
# If a vertex has dependencies, it will be placed in the lowest layer index of its dependencies
|
||||
# minus 1
|
||||
for vertex_id, deps in successor_map.items():
|
||||
indexes = [vertex_to_layer[dep] for dep in deps if dep in vertex_to_layer]
|
||||
new_layer_index = max(min(indexes, default=0) - 1, 0)
|
||||
new_layer_index_map[vertex_id] = new_layer_index
|
||||
|
||||
for layer_index, layer in enumerate(initial_layers):
|
||||
for vertex_id in layer:
|
||||
# Place the vertex in the highest possible layer where its dependencies are met
|
||||
new_layer_index = new_layer_index_map[vertex_id]
|
||||
if new_layer_index > layer_index:
|
||||
refined_layers[new_layer_index].append(vertex_id)
|
||||
vertex_to_layer[vertex_id] = new_layer_index
|
||||
else:
|
||||
refined_layers[layer_index].append(vertex_id)
|
||||
|
||||
# Remove empty layers if any
|
||||
return [layer for layer in refined_layers if layer]
|
||||
|
||||
|
||||
def _max_dependency_index(
|
||||
vertex_id: str,
|
||||
index_map: dict[str, int],
|
||||
get_vertex_successors: Callable[[str], list[str]],
|
||||
) -> int:
|
||||
"""Finds the highest index a given vertex's dependencies occupy in the same layer.
|
||||
|
||||
Args:
|
||||
vertex_id: ID of the vertex to check
|
||||
index_map: Map of vertex IDs to their indices in the layer
|
||||
get_vertex_successors: Function to get the successor IDs of a vertex
|
||||
|
||||
Returns:
|
||||
The highest index of the vertex's dependencies
|
||||
"""
|
||||
max_index = -1
|
||||
for successor_id in get_vertex_successors(vertex_id):
|
||||
successor_index = index_map.get(successor_id, -1)
|
||||
max_index = max(successor_index, max_index)
|
||||
return max_index
|
||||
|
||||
|
||||
def _sort_single_layer_by_dependency(
|
||||
layer: list[str],
|
||||
get_vertex_successors: Callable[[str], list[str]],
|
||||
) -> list[str]:
|
||||
"""Sorts a single layer by dependency using a stable sorting method.
|
||||
|
||||
Args:
|
||||
layer: List of vertex IDs in the layer
|
||||
get_vertex_successors: Function to get the successor IDs of a vertex
|
||||
|
||||
Returns:
|
||||
Sorted list of vertex IDs
|
||||
"""
|
||||
# Build a map of each vertex to its index in the layer for quick lookup.
|
||||
index_map = {vertex: index for index, vertex in enumerate(layer)}
|
||||
dependency_cache: dict[str, int] = {}
|
||||
|
||||
def max_dependency_index(vertex: str) -> int:
|
||||
if vertex in dependency_cache:
|
||||
return dependency_cache[vertex]
|
||||
max_index = index_map[vertex]
|
||||
for successor in get_vertex_successors(vertex):
|
||||
if successor in index_map:
|
||||
max_index = max(max_index, max_dependency_index(successor))
|
||||
|
||||
dependency_cache[vertex] = max_index
|
||||
return max_index
|
||||
|
||||
return sorted(layer, key=max_dependency_index, reverse=True)
|
||||
|
||||
|
||||
def sort_layer_by_dependency(
|
||||
vertices_layers: list[list[str]],
|
||||
get_vertex_successors: Callable[[str], list[str]],
|
||||
) -> list[list[str]]:
|
||||
"""Sorts the vertices in each layer by dependency, ensuring no vertex depends on a subsequent vertex.
|
||||
|
||||
Args:
|
||||
vertices_layers: List of layers, where each layer is a list of vertex IDs
|
||||
get_vertex_successors: Function to get the successor IDs of a vertex
|
||||
|
||||
Returns:
|
||||
Sorted layers
|
||||
"""
|
||||
return [_sort_single_layer_by_dependency(layer, get_vertex_successors) for layer in vertices_layers]
|
||||
|
||||
|
||||
def sort_chat_inputs_first(
|
||||
vertices_layers: list[list[str]],
|
||||
get_vertex_predecessors: Callable[[str], list[str]],
|
||||
) -> list[list[str]]:
|
||||
"""Sorts the vertices so that chat inputs come first in the layers.
|
||||
|
||||
Only one chat input is allowed in the entire graph.
|
||||
|
||||
Args:
|
||||
vertices_layers: List of layers, where each layer is a list of vertex IDs
|
||||
get_vertex_predecessors: Function to get the predecessor IDs of a vertex
|
||||
|
||||
Returns:
|
||||
Sorted layers with single chat input first
|
||||
|
||||
Raises:
|
||||
ValueError: If there are multiple chat inputs in the graph
|
||||
"""
|
||||
chat_input = None
|
||||
chat_input_layer_idx = None
|
||||
|
||||
# Find chat input and validate only one exists
|
||||
for layer_idx, layer in enumerate(vertices_layers):
|
||||
for vertex_id in layer:
|
||||
if "ChatInput" in vertex_id and get_vertex_predecessors(vertex_id):
|
||||
return vertices_layers
|
||||
if "ChatInput" in vertex_id:
|
||||
if chat_input is not None:
|
||||
msg = "Only one chat input is allowed in the graph"
|
||||
raise ValueError(msg)
|
||||
chat_input = vertex_id
|
||||
chat_input_layer_idx = layer_idx
|
||||
|
||||
if not chat_input:
|
||||
return vertices_layers
|
||||
|
||||
# If chat input already in first layer, just move it to index 0
|
||||
if chat_input_layer_idx == 0:
|
||||
first_layer = vertices_layers[0]
|
||||
first_layer.remove(chat_input)
|
||||
first_layer.insert(0, chat_input)
|
||||
return vertices_layers
|
||||
|
||||
# Otherwise create new layers with chat input first
|
||||
result_layers = []
|
||||
for layer in vertices_layers:
|
||||
layer_vertices = [v for v in layer if v != chat_input]
|
||||
if layer_vertices:
|
||||
result_layers.append(layer_vertices)
|
||||
|
||||
return [[chat_input], *result_layers]
|
||||
|
||||
|
||||
def get_sorted_vertices(
|
||||
vertices_ids: list[str],
|
||||
cycle_vertices: set[str],
|
||||
stop_component_id: str | None = None,
|
||||
start_component_id: str | None = None,
|
||||
graph_dict: dict[str, Any] | None = None,
|
||||
in_degree_map: dict[str, int] | None = None,
|
||||
successor_map: dict[str, list[str]] | None = None,
|
||||
predecessor_map: dict[str, list[str]] | None = None,
|
||||
is_input_vertex: Callable[[str], bool] | None = None,
|
||||
get_vertex_predecessors: Callable[[str], list[str]] | None = None,
|
||||
get_vertex_successors: Callable[[str], list[str]] | None = None,
|
||||
*,
|
||||
is_cyclic: bool = False,
|
||||
) -> tuple[list[str], list[list[str]]]:
|
||||
"""Get sorted vertices in a graph.
|
||||
|
||||
Args:
|
||||
vertices_ids: List of vertex IDs to sort
|
||||
cycle_vertices: Set of vertices that form a cycle
|
||||
stop_component_id: ID of the stop component (if any)
|
||||
start_component_id: ID of the start component (if any)
|
||||
graph_dict: Dictionary containing graph information
|
||||
in_degree_map: Map of vertex IDs to their in-degree
|
||||
successor_map: Map of vertex IDs to their successors
|
||||
predecessor_map: Map of vertex IDs to their predecessors
|
||||
is_input_vertex: Function to check if a vertex is an input vertex
|
||||
get_vertex_predecessors: Function to get predecessors of a vertex
|
||||
get_vertex_successors: Function to get successors of a vertex
|
||||
is_cyclic: Whether the graph is cyclic
|
||||
|
||||
Returns:
|
||||
Tuple of (first layer vertices, remaining layer vertices)
|
||||
"""
|
||||
# Handle cycles by converting stop to start
|
||||
if stop_component_id in cycle_vertices:
|
||||
start_component_id = stop_component_id
|
||||
stop_component_id = None
|
||||
|
||||
# Build in_degree_map if not provided
|
||||
if in_degree_map is None:
|
||||
in_degree_map = {}
|
||||
for vertex_id in vertices_ids:
|
||||
if get_vertex_predecessors is not None:
|
||||
in_degree_map[vertex_id] = len(get_vertex_predecessors(vertex_id))
|
||||
else:
|
||||
in_degree_map[vertex_id] = 0
|
||||
|
||||
# Build successor_map if not provided
|
||||
if successor_map is None:
|
||||
successor_map = {}
|
||||
for vertex_id in vertices_ids:
|
||||
if get_vertex_successors is not None:
|
||||
successor_map[vertex_id] = get_vertex_successors(vertex_id)
|
||||
else:
|
||||
successor_map[vertex_id] = []
|
||||
|
||||
# Build predecessor_map if not provided
|
||||
if predecessor_map is None:
|
||||
predecessor_map = {}
|
||||
for vertex_id in vertices_ids:
|
||||
if get_vertex_predecessors is not None:
|
||||
predecessor_map[vertex_id] = get_vertex_predecessors(vertex_id)
|
||||
else:
|
||||
predecessor_map[vertex_id] = []
|
||||
|
||||
# If we have a stop component, we need to filter out all vertices
|
||||
# that are not predecessors of the stop component
|
||||
if stop_component_id is not None:
|
||||
filtered_vertices = filter_vertices_up_to_vertex(
|
||||
vertices_ids,
|
||||
stop_component_id,
|
||||
get_vertex_predecessors=get_vertex_predecessors,
|
||||
get_vertex_successors=get_vertex_successors,
|
||||
graph_dict=graph_dict,
|
||||
)
|
||||
vertices_ids = list(filtered_vertices)
|
||||
|
||||
# Get the layers
|
||||
layers = layered_topological_sort(
|
||||
vertices_ids=set(vertices_ids),
|
||||
in_degree_map=in_degree_map,
|
||||
successor_map=successor_map,
|
||||
predecessor_map=predecessor_map,
|
||||
start_id=start_component_id,
|
||||
is_input_vertex=is_input_vertex,
|
||||
cycle_vertices=cycle_vertices,
|
||||
is_cyclic=is_cyclic,
|
||||
)
|
||||
|
||||
# Split into first layer and remaining layers
|
||||
if not layers:
|
||||
return [], []
|
||||
|
||||
first_layer = layers[0]
|
||||
remaining_layers = layers[1:]
|
||||
|
||||
# If we have a stop component, we need to filter out all vertices
|
||||
# that are not predecessors of the stop component
|
||||
if stop_component_id is not None and remaining_layers and stop_component_id not in remaining_layers[-1]:
|
||||
remaining_layers[-1].append(stop_component_id)
|
||||
|
||||
# Sort chat inputs first and sort each layer by dependencies
|
||||
all_layers = [first_layer, *remaining_layers]
|
||||
if get_vertex_predecessors is not None:
|
||||
all_layers = sort_chat_inputs_first(all_layers, get_vertex_predecessors)
|
||||
if get_vertex_successors is not None:
|
||||
all_layers = sort_layer_by_dependency(all_layers, get_vertex_successors)
|
||||
|
||||
if not all_layers:
|
||||
return [], []
|
||||
|
||||
return all_layers[0], all_layers[1:]
|
||||
|
||||
|
||||
def filter_vertices_up_to_vertex(
|
||||
vertices_ids: list[str],
|
||||
vertex_id: str,
|
||||
get_vertex_predecessors: Callable[[str], list[str]] | None = None,
|
||||
get_vertex_successors: Callable[[str], list[str]] | None = None,
|
||||
graph_dict: dict[str, Any] | None = None,
|
||||
) -> set[str]:
|
||||
"""Filter vertices up to a given vertex.
|
||||
|
||||
Args:
|
||||
vertices_ids: List of vertex IDs to filter
|
||||
vertex_id: ID of the vertex to filter up to
|
||||
get_vertex_predecessors: Function to get predecessors of a vertex
|
||||
get_vertex_successors: Function to get successors of a vertex
|
||||
graph_dict: Dictionary containing graph information
|
||||
parent_node_map: Map of vertex IDs to their parent node IDs
|
||||
|
||||
Returns:
|
||||
Set of vertex IDs that are predecessors of the given vertex
|
||||
"""
|
||||
vertices_set = set(vertices_ids)
|
||||
if vertex_id not in vertices_set:
|
||||
return set()
|
||||
|
||||
# Build predecessor map if not provided
|
||||
if get_vertex_predecessors is None:
|
||||
if graph_dict is None:
|
||||
return set()
|
||||
|
||||
def get_vertex_predecessors(v):
|
||||
return graph_dict[v]["predecessors"]
|
||||
|
||||
# Build successor map if not provided
|
||||
if get_vertex_successors is None:
|
||||
if graph_dict is None:
|
||||
return set()
|
||||
|
||||
def get_vertex_successors(v):
|
||||
return graph_dict[v]["successors"]
|
||||
|
||||
# Start with the target vertex
|
||||
filtered_vertices = {vertex_id}
|
||||
queue = deque([vertex_id])
|
||||
|
||||
# Process vertices in breadth-first order
|
||||
while queue:
|
||||
current_vertex = queue.popleft()
|
||||
for predecessor in get_vertex_predecessors(current_vertex):
|
||||
if predecessor in vertices_set and predecessor not in filtered_vertices:
|
||||
filtered_vertices.add(predecessor)
|
||||
queue.append(predecessor)
|
||||
|
||||
return filtered_vertices
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ from sqlmodel import select
|
|||
from langflow.api.utils import format_elapsed_time
|
||||
from langflow.api.v1.schemas import ResultDataResponse, VertexBuildResponse
|
||||
from langflow.graph.graph.base import Graph
|
||||
from langflow.graph.graph.utils import layered_topological_sort
|
||||
from langflow.graph.utils import log_vertex_build
|
||||
from langflow.graph.vertex.base import Vertex
|
||||
from langflow.services.database.models.flow.model import Flow
|
||||
|
|
@ -32,7 +33,12 @@ async def get_vertices(sio, sid, flow_id, chat_service) -> None:
|
|||
|
||||
graph = Graph.from_payload(flow.data)
|
||||
chat_service.set_cache(flow_id, graph)
|
||||
vertices = graph.layered_topological_sort(graph.vertices)
|
||||
vertices = layered_topological_sort(
|
||||
set(graph.get_vertex_ids()),
|
||||
graph.in_degree_map,
|
||||
graph.successor_map,
|
||||
graph.predecessor_map,
|
||||
)
|
||||
|
||||
# Emit the vertices to the client
|
||||
await sio.emit("vertices_order", data=vertices, to=sid)
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -36,6 +36,24 @@ def graph():
|
|||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def graph_with_loop():
|
||||
return {
|
||||
"Playlist Extractor": {"successors": ["Loop"], "predecessors": []},
|
||||
"Loop": {
|
||||
"successors": ["Parse Data 1", "Parse Data 2"],
|
||||
"predecessors": ["Playlist Extractor", "YouTube Transcripts"],
|
||||
},
|
||||
"Parse Data 1": {"successors": ["YouTube Transcripts"], "predecessors": ["Loop"]},
|
||||
"Parse Data 2": {"successors": ["Message to Data"], "predecessors": ["Loop"]},
|
||||
"YouTube Transcripts": {"successors": ["Loop"], "predecessors": ["Parse Data 1"]},
|
||||
"Message to Data": {"successors": ["Split Text"], "predecessors": ["Parse Data 2"]},
|
||||
"Split Text": {"successors": ["Chroma DB"], "predecessors": ["Message to Data"]},
|
||||
"OpenAI Embeddings": {"successors": ["Chroma DB"], "predecessors": []},
|
||||
"Chroma DB": {"successors": [], "predecessors": ["Split Text", "OpenAI Embeddings"]},
|
||||
}
|
||||
|
||||
|
||||
def test_get_successors_a(graph):
|
||||
vertex_id = "A"
|
||||
|
||||
|
|
@ -202,10 +220,22 @@ class TestFindCycleEdge:
|
|||
|
||||
# Handles large graphs efficiently
|
||||
def test_large_graph_efficiency(self):
|
||||
entry_point = "0"
|
||||
edges = [(str(i), str(i + 1)) for i in range(1000)] + [("999", "0")]
|
||||
entry_point = "A"
|
||||
# Create a graph with 50 nodes that definitely contains cycles
|
||||
base_edges = [(chr(65 + i), chr(65 + (i + 1) % 26)) for i in range(25)]
|
||||
cycle_edges = [(chr(65 + i), chr(65 + (i - 2) % 26)) for i in range(2, 25, 3)]
|
||||
edges = base_edges + cycle_edges
|
||||
|
||||
result = utils.find_cycle_edge(entry_point, edges)
|
||||
assert result == ("999", "0")
|
||||
|
||||
assert result is not None, (
|
||||
"No cycle was found, but the graph should contain cycles.\n"
|
||||
f"Entry point: {entry_point}\n"
|
||||
f"Number of edges: {len(edges)}"
|
||||
)
|
||||
assert isinstance(result, tuple), f"Expected result to be a tuple, but got {type(result)}"
|
||||
assert len(result) == 2, f"Expected tuple of length 2, but got length {len(result)}"
|
||||
assert all(isinstance(x, str) for x in result), "Expected both elements to be strings"
|
||||
|
||||
# Manages graphs with duplicate edges
|
||||
def test_duplicate_edges(self):
|
||||
|
|
@ -444,3 +474,379 @@ class TestFindCycleVertices:
|
|||
expected_output = ["router", "chat_input", "concatenate"]
|
||||
result = utils.find_cycle_vertices(edges)
|
||||
assert sorted(result) == sorted(expected_output)
|
||||
|
||||
|
||||
def test_chat_inputs_at_start():
|
||||
vertices_layers = [["ChatInput1", "B"], ["C"], ["D"]]
|
||||
|
||||
def get_vertex_predecessors(vertex_id: str) -> list[str]: # noqa: ARG001
|
||||
return []
|
||||
|
||||
result = utils.sort_chat_inputs_first(vertices_layers, get_vertex_predecessors)
|
||||
assert len(result) == 3 # [chat_input] + original 3 layers
|
||||
assert result[0] == ["ChatInput1", "B"]
|
||||
assert result[1] == ["C"] # Original second layer
|
||||
assert result[2] == ["D"] # Original third layer
|
||||
|
||||
# Test that multiple chat inputs raise an error
|
||||
vertices_layers_multiple = [["ChatInput1", "B"], ["ChatInput2", "C"], ["D"]]
|
||||
with pytest.raises(ValueError, match="Only one chat input is allowed in the graph"):
|
||||
utils.sort_chat_inputs_first(vertices_layers_multiple, get_vertex_predecessors)
|
||||
|
||||
|
||||
def test_get_sorted_vertices_simple():
|
||||
# Simple graph with chat input
|
||||
vertices_ids = ["ChatInput1", "B", "C", "D"]
|
||||
cycle_vertices = set()
|
||||
graph_dict = {
|
||||
"ChatInput1": {"successors": ["B"], "predecessors": []},
|
||||
"B": {"successors": ["C"], "predecessors": ["ChatInput1"]},
|
||||
"C": {"successors": ["D"], "predecessors": ["B"]},
|
||||
"D": {"successors": [], "predecessors": ["C"]},
|
||||
}
|
||||
in_degree_map = {"ChatInput1": 0, "B": 1, "C": 1, "D": 1}
|
||||
successor_map = {"ChatInput1": ["B"], "B": ["C"], "C": ["D"], "D": []}
|
||||
predecessor_map = {"ChatInput1": [], "B": ["ChatInput1"], "C": ["B"], "D": ["C"]}
|
||||
|
||||
def is_input_vertex(vertex_id: str) -> bool:
|
||||
return vertex_id == "ChatInput1"
|
||||
|
||||
def get_vertex_predecessors(vertex_id: str) -> list[str]:
|
||||
return predecessor_map[vertex_id]
|
||||
|
||||
def get_vertex_successors(vertex_id: str) -> list[str]:
|
||||
return successor_map[vertex_id]
|
||||
|
||||
first_layer, remaining_layers = utils.get_sorted_vertices(
|
||||
vertices_ids=vertices_ids,
|
||||
cycle_vertices=cycle_vertices,
|
||||
stop_component_id=None,
|
||||
start_component_id=None,
|
||||
graph_dict=graph_dict,
|
||||
in_degree_map=in_degree_map,
|
||||
successor_map=successor_map,
|
||||
predecessor_map=predecessor_map,
|
||||
is_input_vertex=is_input_vertex,
|
||||
get_vertex_predecessors=get_vertex_predecessors,
|
||||
get_vertex_successors=get_vertex_successors,
|
||||
is_cyclic=False,
|
||||
)
|
||||
|
||||
assert first_layer == ["ChatInput1"]
|
||||
assert len(remaining_layers) == 3
|
||||
assert remaining_layers[0] == ["B"]
|
||||
assert remaining_layers[1] == ["C"]
|
||||
assert remaining_layers[2] == ["D"]
|
||||
|
||||
|
||||
def test_get_sorted_vertices_with_cycle():
|
||||
# Graph with a cycle
|
||||
vertices_ids = ["A", "B", "C"]
|
||||
cycle_vertices = {"A", "B", "C"}
|
||||
graph_dict = {
|
||||
"A": {"successors": ["B"], "predecessors": ["C"]},
|
||||
"B": {"successors": ["C"], "predecessors": ["A"]},
|
||||
"C": {"successors": ["A"], "predecessors": ["B"]},
|
||||
}
|
||||
in_degree_map = {"A": 1, "B": 1, "C": 1}
|
||||
successor_map = {"A": ["B"], "B": ["C"], "C": ["A"]}
|
||||
predecessor_map = {"A": ["C"], "B": ["A"], "C": ["B"]}
|
||||
|
||||
def is_input_vertex(vertex_id: str) -> bool: # noqa: ARG001
|
||||
return False
|
||||
|
||||
def get_vertex_predecessors(vertex_id: str) -> list[str]:
|
||||
return predecessor_map[vertex_id]
|
||||
|
||||
def get_vertex_successors(vertex_id: str) -> list[str]:
|
||||
return successor_map[vertex_id]
|
||||
|
||||
# Test with stop_component_id in cycle
|
||||
first_layer, remaining_layers = utils.get_sorted_vertices(
|
||||
vertices_ids=vertices_ids,
|
||||
cycle_vertices=cycle_vertices,
|
||||
stop_component_id="B",
|
||||
start_component_id=None,
|
||||
graph_dict=graph_dict,
|
||||
in_degree_map=in_degree_map,
|
||||
successor_map=successor_map,
|
||||
predecessor_map=predecessor_map,
|
||||
is_input_vertex=is_input_vertex,
|
||||
get_vertex_predecessors=get_vertex_predecessors,
|
||||
get_vertex_successors=get_vertex_successors,
|
||||
is_cyclic=True,
|
||||
)
|
||||
|
||||
# When there's a cycle and stop_component_id is in the cycle,
|
||||
# stop_component_id becomes start_component_id
|
||||
assert first_layer == ["B"]
|
||||
assert len(remaining_layers) == 2
|
||||
assert remaining_layers[0] == ["C"]
|
||||
assert remaining_layers[1] == ["A"]
|
||||
|
||||
|
||||
def test_get_sorted_vertices_with_stop():
|
||||
# Graph with a stop component
|
||||
vertices_ids = ["A", "B", "C", "D", "E"]
|
||||
cycle_vertices = set()
|
||||
graph_dict = {
|
||||
"A": {"successors": ["B"], "predecessors": []},
|
||||
"B": {"successors": ["C"], "predecessors": ["A"]},
|
||||
"C": {"successors": ["D"], "predecessors": ["B"]},
|
||||
"D": {"successors": ["E"], "predecessors": ["C"]},
|
||||
"E": {"successors": [], "predecessors": ["D"]},
|
||||
}
|
||||
in_degree_map = {"A": 0, "B": 1, "C": 1, "D": 1, "E": 1}
|
||||
successor_map = {"A": ["B"], "B": ["C"], "C": ["D"], "D": ["E"], "E": []}
|
||||
predecessor_map = {"A": [], "B": ["A"], "C": ["B"], "D": ["C"], "E": ["D"]}
|
||||
|
||||
def is_input_vertex(vertex_id: str) -> bool:
|
||||
return vertex_id == "A"
|
||||
|
||||
def get_vertex_predecessors(vertex_id: str) -> list[str]:
|
||||
return predecessor_map[vertex_id]
|
||||
|
||||
def get_vertex_successors(vertex_id: str) -> list[str]:
|
||||
return successor_map[vertex_id]
|
||||
|
||||
first_layer, remaining_layers = utils.get_sorted_vertices(
|
||||
vertices_ids=vertices_ids,
|
||||
cycle_vertices=cycle_vertices,
|
||||
stop_component_id="C",
|
||||
start_component_id=None,
|
||||
graph_dict=graph_dict,
|
||||
in_degree_map=in_degree_map,
|
||||
successor_map=successor_map,
|
||||
predecessor_map=predecessor_map,
|
||||
is_input_vertex=is_input_vertex,
|
||||
get_vertex_predecessors=get_vertex_predecessors,
|
||||
get_vertex_successors=get_vertex_successors,
|
||||
is_cyclic=False,
|
||||
)
|
||||
|
||||
assert first_layer == ["A"]
|
||||
assert len(remaining_layers) == 2
|
||||
assert remaining_layers[0] == ["B"]
|
||||
assert remaining_layers[1] == ["C"]
|
||||
|
||||
|
||||
def test_get_sorted_vertices_with_complex_cycle(graph_with_loop):
|
||||
# Convert the graph structure to the format needed by get_sorted_vertices
|
||||
vertices_ids = list(graph_with_loop.keys())
|
||||
cycle_vertices = {"Loop", "Parse Data 1", "YouTube Transcripts"} # Known cycle in the graph
|
||||
graph_dict = graph_with_loop
|
||||
|
||||
# Build in_degree_map from predecessors
|
||||
in_degree_map = {vertex: len(data["predecessors"]) for vertex, data in graph_with_loop.items()}
|
||||
|
||||
# Build successor and predecessor maps
|
||||
successor_map = {vertex: data["successors"] for vertex, data in graph_with_loop.items()}
|
||||
predecessor_map = {vertex: data["predecessors"] for vertex, data in graph_with_loop.items()}
|
||||
|
||||
def is_input_vertex(vertex_id: str) -> bool:
|
||||
# Only Playlist Extractor is an input vertex
|
||||
return vertex_id == "Playlist Extractor"
|
||||
|
||||
def get_vertex_predecessors(vertex_id: str) -> list[str]:
|
||||
return predecessor_map[vertex_id]
|
||||
|
||||
def get_vertex_successors(vertex_id: str) -> list[str]:
|
||||
return successor_map[vertex_id]
|
||||
|
||||
# Test with the cycle
|
||||
first_layer, remaining_layers = utils.get_sorted_vertices(
|
||||
vertices_ids=vertices_ids,
|
||||
cycle_vertices=cycle_vertices,
|
||||
stop_component_id=None,
|
||||
start_component_id=None,
|
||||
graph_dict=graph_dict,
|
||||
in_degree_map=in_degree_map,
|
||||
successor_map=successor_map,
|
||||
predecessor_map=predecessor_map,
|
||||
is_input_vertex=is_input_vertex,
|
||||
get_vertex_predecessors=get_vertex_predecessors,
|
||||
get_vertex_successors=get_vertex_successors,
|
||||
is_cyclic=True,
|
||||
)
|
||||
|
||||
# When is_cyclic is True and start_vertex_id is provided:
|
||||
# 1. The first layer will contain vertices with no predecessors and vertices that are part of the cycle
|
||||
# 2. This is because the cycle vertices are treated as having no dependencies in the initial sort
|
||||
assert (
|
||||
"OpenAI Embeddings" in first_layer
|
||||
), "Vertex with no predecessors 'OpenAI Embeddings' should be in first layer"
|
||||
assert "Playlist Extractor" in first_layer, "Input vertex 'Playlist Extractor' should be in first layer"
|
||||
assert (
|
||||
len(first_layer) == 2
|
||||
), f"First layer should contain exactly 4 vertices, got {len(first_layer)}: {first_layer}"
|
||||
|
||||
# Verify that the remaining layers contain the rest of the vertices in the correct order
|
||||
# The graph structure shows:
|
||||
# Loop -> Parse Data 2 -> Message to Data -> Split Text -> Chroma DB
|
||||
# OpenAI Embeddings -> Chroma DB
|
||||
vertex_to_layer = {}
|
||||
for i, layer in enumerate(remaining_layers):
|
||||
for vertex in layer:
|
||||
vertex_to_layer[vertex] = i
|
||||
|
||||
# Verify that vertices appear in the correct order
|
||||
assert "Loop" in vertex_to_layer, "Vertex 'Loop' should be present in remaining layers"
|
||||
assert "Parse Data 2" in vertex_to_layer, "Vertex 'Parse Data 2' should be present in remaining layers"
|
||||
assert "Message to Data" in vertex_to_layer, "Vertex 'Message to Data' should be present in remaining layers"
|
||||
assert "Chroma DB" in vertex_to_layer, "Vertex 'Chroma DB' should be present in remaining layers"
|
||||
|
||||
# Verify the dependencies are respected
|
||||
# Note: Due to the cycle and the way layered_topological_sort works,
|
||||
# some vertices might appear in earlier layers than expected
|
||||
# What's important is that the dependencies are respected within the non-cycle components
|
||||
assert vertex_to_layer["Parse Data 2"] <= vertex_to_layer["Message to Data"], (
|
||||
f"'Parse Data 2' (layer {vertex_to_layer['Parse Data 2']}) should appear in same or earlier layer than "
|
||||
f"'Message to Data' (layer {vertex_to_layer['Message to Data']})"
|
||||
)
|
||||
|
||||
|
||||
def test_get_sorted_vertices_with_stop_at_chroma(graph_with_loop):
|
||||
# Convert the graph structure to the format needed by get_sorted_vertices
|
||||
vertices_ids = list(graph_with_loop.keys())
|
||||
cycle_vertices = {"Loop", "Parse Data 1", "YouTube Transcripts"} # Known cycle in the graph
|
||||
graph_dict = graph_with_loop
|
||||
|
||||
# Build in_degree_map from predecessors
|
||||
in_degree_map = {vertex: len(data["predecessors"]) for vertex, data in graph_with_loop.items()}
|
||||
|
||||
# Build successor and predecessor maps
|
||||
successor_map = {vertex: data["successors"] for vertex, data in graph_with_loop.items()}
|
||||
predecessor_map = {vertex: data["predecessors"] for vertex, data in graph_with_loop.items()}
|
||||
|
||||
def is_input_vertex(vertex_id: str) -> bool:
|
||||
# Only Playlist Extractor is an input vertex
|
||||
return vertex_id == "Playlist Extractor"
|
||||
|
||||
def get_vertex_predecessors(vertex_id: str) -> list[str]:
|
||||
return predecessor_map[vertex_id]
|
||||
|
||||
def get_vertex_successors(vertex_id: str) -> list[str]:
|
||||
return successor_map[vertex_id]
|
||||
|
||||
# Test with ChromaDB as stop component
|
||||
first_layer, remaining_layers = utils.get_sorted_vertices(
|
||||
vertices_ids=vertices_ids,
|
||||
cycle_vertices=cycle_vertices,
|
||||
stop_component_id="Chroma DB", # Stop at ChromaDB
|
||||
start_component_id=None,
|
||||
graph_dict=graph_dict,
|
||||
in_degree_map=in_degree_map,
|
||||
successor_map=successor_map,
|
||||
predecessor_map=predecessor_map,
|
||||
is_input_vertex=is_input_vertex,
|
||||
get_vertex_predecessors=get_vertex_predecessors,
|
||||
get_vertex_successors=get_vertex_successors,
|
||||
is_cyclic=True,
|
||||
)
|
||||
|
||||
# When is_cyclic is True and we have a stop component:
|
||||
# 1. The first layer will contain vertices with no predecessors and vertices that are part of the cycle
|
||||
# 2. This is because the cycle vertices are treated as having no dependencies in the initial sort
|
||||
assert (
|
||||
"OpenAI Embeddings" in first_layer
|
||||
), "Vertex with no predecessors 'OpenAI Embeddings' should be in first layer"
|
||||
assert "Playlist Extractor" in first_layer, "Input vertex 'Playlist Extractor' should be in first layer"
|
||||
|
||||
assert (
|
||||
len(first_layer) == 2
|
||||
), f"First layer should contain exactly 4 vertices, got {len(first_layer)}: {first_layer}"
|
||||
|
||||
# Verify that the remaining layers contain the rest of the vertices in the correct order
|
||||
# The graph structure shows:
|
||||
# Loop -> Parse Data 2 -> Message to Data -> Split Text -> Chroma DB
|
||||
# OpenAI Embeddings -> Chroma DB
|
||||
vertex_to_layer = {}
|
||||
for i, layer in enumerate(remaining_layers):
|
||||
for vertex in layer:
|
||||
vertex_to_layer[vertex] = i
|
||||
|
||||
# Verify that vertices appear in the correct order
|
||||
assert "Loop" in vertex_to_layer, "Vertex 'Loop' should be present in remaining layers"
|
||||
assert "Parse Data 2" in vertex_to_layer, "Vertex 'Parse Data 2' should be present in remaining layers"
|
||||
assert "Message to Data" in vertex_to_layer, "Vertex 'Message to Data' should be present in remaining layers"
|
||||
assert "Chroma DB" in vertex_to_layer, "Vertex 'Chroma DB' should be present in remaining layers"
|
||||
|
||||
# Verify that dependencies are respected
|
||||
assert vertex_to_layer["Parse Data 2"] <= vertex_to_layer["Message to Data"], (
|
||||
f"'Parse Data 2' (layer {vertex_to_layer['Parse Data 2']}) should appear in same or earlier layer than "
|
||||
f"'Message to Data' (layer {vertex_to_layer['Message to Data']})"
|
||||
)
|
||||
|
||||
# When a vertex is marked as a stop component, it will appear in layer 0
|
||||
# of the remaining layers. This is because the algorithm stops at this vertex.
|
||||
assert vertex_to_layer["Chroma DB"] == 5, (
|
||||
f"Stop component 'Chroma DB' should be in layer 5, "
|
||||
f"but was found in layer {vertex_to_layer['Chroma DB']}. "
|
||||
f"Remaining layers: {remaining_layers}"
|
||||
)
|
||||
|
||||
|
||||
def test_get_sorted_vertices_exact_sequence(graph_with_loop):
|
||||
# Convert the graph structure to the format needed by get_sorted_vertices
|
||||
vertices_ids = list(graph_with_loop.keys())
|
||||
cycle_vertices = {"Loop", "Parse Data 1", "YouTube Transcripts"} # Known cycle in the graph
|
||||
graph_dict = graph_with_loop
|
||||
|
||||
# Build in_degree_map from predecessors
|
||||
in_degree_map = {vertex: len(data["predecessors"]) for vertex, data in graph_with_loop.items()}
|
||||
|
||||
# Build successor and predecessor maps
|
||||
successor_map = {vertex: data["successors"] for vertex, data in graph_with_loop.items()}
|
||||
predecessor_map = {vertex: data["predecessors"] for vertex, data in graph_with_loop.items()}
|
||||
|
||||
def is_input_vertex(vertex_id: str) -> bool:
|
||||
# Only Playlist Extractor is an input vertex
|
||||
return vertex_id == "Playlist Extractor"
|
||||
|
||||
def get_vertex_predecessors(vertex_id: str) -> list[str]:
|
||||
return predecessor_map[vertex_id]
|
||||
|
||||
def get_vertex_successors(vertex_id: str) -> list[str]:
|
||||
return successor_map[vertex_id]
|
||||
|
||||
# Test with the cycle
|
||||
first_layer, remaining_layers = utils.get_sorted_vertices(
|
||||
vertices_ids=vertices_ids,
|
||||
cycle_vertices=cycle_vertices,
|
||||
stop_component_id=None,
|
||||
start_component_id=None,
|
||||
graph_dict=graph_dict,
|
||||
in_degree_map=in_degree_map,
|
||||
successor_map=successor_map,
|
||||
predecessor_map=predecessor_map,
|
||||
is_input_vertex=is_input_vertex,
|
||||
get_vertex_predecessors=get_vertex_predecessors,
|
||||
get_vertex_successors=get_vertex_successors,
|
||||
is_cyclic=True,
|
||||
)
|
||||
|
||||
# Convert layers to a flat sequence
|
||||
sequence = []
|
||||
sequence.extend(sorted(first_layer))
|
||||
for layer in remaining_layers:
|
||||
sequence.extend(sorted(layer))
|
||||
|
||||
# Expected sequence
|
||||
expected_sequence = [
|
||||
"OpenAI Embeddings",
|
||||
"Playlist Extractor",
|
||||
"YouTube Transcripts",
|
||||
"Loop",
|
||||
"Parse Data 1",
|
||||
"Parse Data 2",
|
||||
"Message to Data",
|
||||
"Split Text",
|
||||
"Chroma DB",
|
||||
]
|
||||
|
||||
# Check each vertex appears in the correct order
|
||||
assert sequence == expected_sequence, f"Sequence: {sequence}"
|
||||
# Verify the exact sequence
|
||||
assert len(sequence) == len(expected_sequence), (
|
||||
f"Expected sequence length {len(expected_sequence)}, " f"but got {len(sequence)}"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ async def consume_and_assert_stream(r):
|
|||
assert parsed["event"] == "vertices_sorted"
|
||||
ids = parsed["data"]["ids"]
|
||||
ids.sort()
|
||||
assert ids == ["ChatInput-CIGht"]
|
||||
assert ids == ["ChatInput-CIGht", "Memory-amN4Z"]
|
||||
|
||||
to_run = parsed["data"]["to_run"]
|
||||
to_run.sort()
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ async def test_get_vertices(client, added_flow_webhook_test, logged_in_headers):
|
|||
# The important part is before the - (ConversationBufferMemory, PromptTemplate, ChatOpenAI, LLMChain)
|
||||
ids = [_id.split("-")[0] for _id in response.json()["ids"]]
|
||||
|
||||
assert set(ids) == {"ChatInput"}
|
||||
assert set(ids) == {"ChatInput", "Webhook"}
|
||||
|
||||
|
||||
async def test_build_vertex_invalid_flow_id(client, logged_in_headers):
|
||||
|
|
|
|||
|
|
@ -23,15 +23,16 @@ async def test_webhook_endpoint(client, added_webhook_test):
|
|||
|
||||
response = await client.post(endpoint, json=payload)
|
||||
assert response.status_code == 202
|
||||
assert await file_path.exists()
|
||||
|
||||
assert not await file_path.exists()
|
||||
# Wait a few seconds for the file to be created
|
||||
assert await file_path.exists(), f"File {file_path} does not exist"
|
||||
file_does_not_exist = not await file_path.exists()
|
||||
assert file_does_not_exist, f"File {file_path} still exists"
|
||||
|
||||
# Send an invalid payload
|
||||
payload = {"invalid_key": "invalid_value"}
|
||||
response = await client.post(endpoint, json=payload)
|
||||
assert response.status_code == 202
|
||||
assert not await file_path.exists()
|
||||
assert not await file_path.exists(), f"File {file_path} should not exist"
|
||||
|
||||
|
||||
async def test_webhook_flow_on_run_endpoint(client, added_webhook_test, created_api_key):
|
||||
|
|
@ -50,7 +51,6 @@ async def test_webhook_with_random_payload(client, added_webhook_test):
|
|||
endpoint_name = added_webhook_test["endpoint_name"]
|
||||
endpoint = f"api/v1/webhook/{endpoint_name}"
|
||||
# Just test that "Random Payload" returns 202
|
||||
# returns 202
|
||||
response = await client.post(
|
||||
endpoint,
|
||||
json="Random Payload",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue