variable_pool.py 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223
  1. import re
  2. from collections import defaultdict
  3. from collections.abc import Mapping, Sequence
  4. from typing import Annotated, Any, Union, cast
  5. from pydantic import BaseModel, Field
  6. from core.file import File, FileAttribute, file_manager
  7. from core.variables import Segment, SegmentGroup, Variable
  8. from core.variables.consts import SELECTORS_LENGTH
  9. from core.variables.segments import FileSegment, ObjectSegment
  10. from core.variables.variables import VariableUnion
  11. from core.workflow.constants import CONVERSATION_VARIABLE_NODE_ID, ENVIRONMENT_VARIABLE_NODE_ID, SYSTEM_VARIABLE_NODE_ID
  12. from core.workflow.system_variable import SystemVariable
  13. from factories import variable_factory
  14. VariableValue = Union[str, int, float, dict, list, File]
  15. VARIABLE_PATTERN = re.compile(r"\{\{#([a-zA-Z0-9_]{1,50}(?:\.[a-zA-Z_][a-zA-Z0-9_]{0,29}){1,10})#\}\}")
  16. class VariablePool(BaseModel):
  17. # Variable dictionary is a dictionary for looking up variables by their selector.
  18. # The first element of the selector is the node id, it's the first-level key in the dictionary.
  19. # Other elements of the selector are the keys in the second-level dictionary. To get the key, we hash the
  20. # elements of the selector except the first one.
  21. variable_dictionary: defaultdict[str, Annotated[dict[str, VariableUnion], Field(default_factory=dict)]] = Field(
  22. description="Variables mapping",
  23. default=defaultdict(dict),
  24. )
  25. # The `user_inputs` is used only when constructing the inputs for the `StartNode`. It's not used elsewhere.
  26. user_inputs: Mapping[str, Any] = Field(
  27. description="User inputs",
  28. default_factory=dict,
  29. )
  30. system_variables: SystemVariable = Field(
  31. description="System variables",
  32. default_factory=SystemVariable.empty,
  33. )
  34. environment_variables: Sequence[VariableUnion] = Field(
  35. description="Environment variables.",
  36. default_factory=list,
  37. )
  38. conversation_variables: Sequence[VariableUnion] = Field(
  39. description="Conversation variables.",
  40. default_factory=list,
  41. )
  42. def model_post_init(self, context: Any, /) -> None:
  43. # Create a mapping from field names to SystemVariableKey enum values
  44. self._add_system_variables(self.system_variables)
  45. # Add environment variables to the variable pool
  46. for var in self.environment_variables:
  47. self.add((ENVIRONMENT_VARIABLE_NODE_ID, var.name), var)
  48. # Add conversation variables to the variable pool
  49. for var in self.conversation_variables:
  50. self.add((CONVERSATION_VARIABLE_NODE_ID, var.name), var)
  51. def add(self, selector: Sequence[str], value: Any, /) -> None:
  52. """
  53. Add a variable to the variable pool.
  54. This method accepts a selector path and a value, converting the value
  55. to a Variable object if necessary before storing it in the pool.
  56. Args:
  57. selector: A two-element sequence containing [node_id, variable_name].
  58. The selector must have exactly 2 elements to be valid.
  59. value: The value to store. Can be a Variable, Segment, or any value
  60. that can be converted to a Segment (str, int, float, dict, list, File).
  61. Raises:
  62. ValueError: If selector length is not exactly 2 elements.
  63. Note:
  64. While non-Segment values are currently accepted and automatically
  65. converted, it's recommended to pass Segment or Variable objects directly.
  66. """
  67. if len(selector) != SELECTORS_LENGTH:
  68. raise ValueError(
  69. f"Invalid selector: expected {SELECTORS_LENGTH} elements (node_id, variable_name), "
  70. f"got {len(selector)} elements"
  71. )
  72. if isinstance(value, Variable):
  73. variable = value
  74. elif isinstance(value, Segment):
  75. variable = variable_factory.segment_to_variable(segment=value, selector=selector)
  76. else:
  77. segment = variable_factory.build_segment(value)
  78. variable = variable_factory.segment_to_variable(segment=segment, selector=selector)
  79. node_id, name = self._selector_to_keys(selector)
  80. # Based on the definition of `VariableUnion`,
  81. # `list[Variable]` can be safely used as `list[VariableUnion]` since they are compatible.
  82. self.variable_dictionary[node_id][name] = cast(VariableUnion, variable)
  83. @classmethod
  84. def _selector_to_keys(cls, selector: Sequence[str]) -> tuple[str, str]:
  85. return selector[0], selector[1]
  86. def _has(self, selector: Sequence[str]) -> bool:
  87. node_id, name = self._selector_to_keys(selector)
  88. if node_id not in self.variable_dictionary:
  89. return False
  90. if name not in self.variable_dictionary[node_id]:
  91. return False
  92. return True
  93. def get(self, selector: Sequence[str], /) -> Segment | None:
  94. """
  95. Retrieve a variable's value from the pool as a Segment.
  96. This method supports both simple selectors [node_id, variable_name] and
  97. extended selectors that include attribute access for FileSegment and
  98. ObjectSegment types.
  99. Args:
  100. selector: A sequence with at least 2 elements:
  101. - [node_id, variable_name]: Returns the full segment
  102. - [node_id, variable_name, attr, ...]: Returns a nested value
  103. from FileSegment (e.g., 'url', 'name') or ObjectSegment
  104. Returns:
  105. The Segment associated with the selector, or None if not found.
  106. Returns None if selector has fewer than 2 elements.
  107. Raises:
  108. ValueError: If attempting to access an invalid FileAttribute.
  109. """
  110. if len(selector) < SELECTORS_LENGTH:
  111. return None
  112. node_id, name = self._selector_to_keys(selector)
  113. segment: Segment | None = self.variable_dictionary[node_id].get(name)
  114. if segment is None:
  115. return None
  116. if len(selector) == 2:
  117. return segment
  118. if isinstance(segment, FileSegment):
  119. attr = selector[2]
  120. # Python support `attr in FileAttribute` after 3.12
  121. if attr not in {item.value for item in FileAttribute}:
  122. return None
  123. attr = FileAttribute(attr)
  124. attr_value = file_manager.get_attr(file=segment.value, attr=attr)
  125. return variable_factory.build_segment(attr_value)
  126. # Navigate through nested attributes
  127. result: Any = segment
  128. for attr in selector[2:]:
  129. result = self._extract_value(result)
  130. result = self._get_nested_attribute(result, attr)
  131. if result is None:
  132. return None
  133. # Return result as Segment
  134. return result if isinstance(result, Segment) else variable_factory.build_segment(result)
  135. def _extract_value(self, obj: Any) -> Any:
  136. """Extract the actual value from an ObjectSegment."""
  137. return obj.value if isinstance(obj, ObjectSegment) else obj
  138. def _get_nested_attribute(self, obj: Mapping[str, Any], attr: str) -> Any:
  139. """Get a nested attribute from a dictionary-like object."""
  140. if not isinstance(obj, dict):
  141. return None
  142. return obj.get(attr)
  143. def remove(self, selector: Sequence[str], /):
  144. """
  145. Remove variables from the variable pool based on the given selector.
  146. Args:
  147. selector (Sequence[str]): A sequence of strings representing the selector.
  148. Returns:
  149. None
  150. """
  151. if not selector:
  152. return
  153. if len(selector) == 1:
  154. self.variable_dictionary[selector[0]] = {}
  155. return
  156. key, hash_key = self._selector_to_keys(selector)
  157. self.variable_dictionary[key].pop(hash_key, None)
  158. def convert_template(self, template: str, /):
  159. parts = VARIABLE_PATTERN.split(template)
  160. segments = []
  161. for part in filter(lambda x: x, parts):
  162. if "." in part and (variable := self.get(part.split("."))):
  163. segments.append(variable)
  164. else:
  165. segments.append(variable_factory.build_segment(part))
  166. return SegmentGroup(value=segments)
  167. def get_file(self, selector: Sequence[str], /) -> FileSegment | None:
  168. segment = self.get(selector)
  169. if isinstance(segment, FileSegment):
  170. return segment
  171. return None
  172. def _add_system_variables(self, system_variable: SystemVariable):
  173. sys_var_mapping = system_variable.to_dict()
  174. for key, value in sys_var_mapping.items():
  175. if value is None:
  176. continue
  177. selector = (SYSTEM_VARIABLE_NODE_ID, key)
  178. # If the system variable already exists, do not add it again.
  179. # This ensures that we can keep the id of the system variables intact.
  180. if self._has(selector):
  181. continue
  182. self.add(selector, value) # type: ignore
  183. @classmethod
  184. def empty(cls) -> "VariablePool":
  185. """Create an empty variable pool."""
  186. return cls(system_variables=SystemVariable.empty())