vulcan-core 1.0.0__tar.gz → 1.1.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of vulcan-core might be problematic. Click here for more details.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: vulcan-core
3
- Version: 1.0.0
3
+ Version: 1.1.1
4
4
  Summary: AI-Hybrid Rules Engine for Logical Reasoning.
5
5
  License: Apache-2.0
6
6
  Keywords: rules,logic,reasoning,ai,artificial intelligence,RAG,LLM
@@ -15,7 +15,7 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
15
15
  Provides-Extra: openai
16
16
  Requires-Dist: langchain ; extra == "openai"
17
17
  Requires-Dist: langchain-openai ; extra == "openai"
18
- Requires-Dist: pydantic (>=2.10.6,<2.11.0)
18
+ Requires-Dist: pydantic (>=2.11.1,<2.12.0)
19
19
  Project-URL: Documentation, https://latchfield.com/vulcan/docs
20
20
  Project-URL: Homepage, https://latchfield.com/vulcan
21
21
  Project-URL: Repository, https://github.com/latchfield/vulcan_core
@@ -26,7 +26,7 @@ Description-Content-Type: text/markdown
26
26
  <img alt="Vulcan Logo" src="https://latchfield.com/vulcan/assets/images/vulcan-logo.svg" height="100px">
27
27
 
28
28
  # AI-Hybrid Rules Engine for Logical Reasoning
29
- ![Version](https://img.shields.io/pypi/v/vulcan_core)
29
+ [![Version](https://img.shields.io/pypi/v/vulcan_core)](https://pypi.org/project/vulcan-core/)
30
30
 
31
31
  Vulcan is an AI-hybrid rules engine designed for advanced automated reasoning. It combines the power of rule-based decision systems with LLMs (Large Language Models) for improved consistency and explainability in AI-powered systems.
32
32
 
@@ -59,14 +59,14 @@ Into repeatable, consistent, and explainable rules:
59
59
  ```python
60
60
  # Use natural language for prediction and data retrieval:
61
61
  engine.rule(
62
- when(f"Are {Apple.kind} considered good for baking?"),
63
- then(Apple(baking=True)),
62
+ when=condition(f"Are {Apple.kind} considered good for baking?"),
63
+ then=action(Apple(baking=True)),
64
64
  )
65
65
 
66
66
  # Use computed logic for operations that must be correct:
67
67
  engine.rule(
68
- when(Apple.baking && lambda: Inventory.apples < 10),
69
- then(Order(apples=10)),
68
+ when=condition(Apple.baking && lambda: Inventory.apples < 10),
69
+ then=action(Order(apples=10)),
70
70
  )
71
71
 
72
72
  # Intelligent on-demand rule evaluation:
@@ -3,7 +3,7 @@
3
3
  <img alt="Vulcan Logo" src="https://latchfield.com/vulcan/assets/images/vulcan-logo.svg" height="100px">
4
4
 
5
5
  # AI-Hybrid Rules Engine for Logical Reasoning
6
- ![Version](https://img.shields.io/pypi/v/vulcan_core)
6
+ [![Version](https://img.shields.io/pypi/v/vulcan_core)](https://pypi.org/project/vulcan-core/)
7
7
 
8
8
  Vulcan is an AI-hybrid rules engine designed for advanced automated reasoning. It combines the power of rule-based decision systems with LLMs (Large Language Models) for improved consistency and explainability in AI-powered systems.
9
9
 
@@ -36,14 +36,14 @@ Into repeatable, consistent, and explainable rules:
36
36
  ```python
37
37
  # Use natural language for prediction and data retrieval:
38
38
  engine.rule(
39
- when(f"Are {Apple.kind} considered good for baking?"),
40
- then(Apple(baking=True)),
39
+ when=condition(f"Are {Apple.kind} considered good for baking?"),
40
+ then=action(Apple(baking=True)),
41
41
  )
42
42
 
43
43
  # Use computed logic for operations that must be correct:
44
44
  engine.rule(
45
- when(Apple.baking && lambda: Inventory.apples < 10),
46
- then(Order(apples=10)),
45
+ when=condition(Apple.baking && lambda: Inventory.apples < 10),
46
+ then=action(Order(apples=10)),
47
47
  )
48
48
 
49
49
  # Intelligent on-demand rule evaluation:
@@ -20,7 +20,7 @@ requires = ["poetry-core"]
20
20
  build-backend = "poetry.core.masonry.api"
21
21
 
22
22
  [tool.poetry]
23
- version = "1.0.0" # Update manually, or use plugin
23
+ version = "1.1.1" # Update manually, or use plugin
24
24
  packages = [{ include = "vulcan_core", from="src" }]
25
25
  requires-poetry = "~2.1.1"
26
26
  classifiers = [
@@ -80,10 +80,13 @@ branch = true # Could be an issue if true for native decoration: https://github.
80
80
  #poetry-plugin-up = "0.9.0"
81
81
 
82
82
  [tool.poetry.dependencies]
83
- pydantic = "~2.10.6"
83
+ pydantic = "~2.11.1"
84
84
  langchain = { version = "~0.3.20", optional = true }
85
85
  langchain-openai = { version = "~0.3.9", optional = true }
86
86
 
87
+ [tool.poetry.extras]
88
+ openai = ["langchain", "langchain-openai"]
89
+
87
90
  [project.optional-dependencies]
88
91
  openai = ["langchain", "langchain-openai"]
89
92
 
@@ -24,7 +24,7 @@ class ContractError(Exception):
24
24
 
25
25
  class ScopeAccessError(ContractError):
26
26
  """Raised when a callable attempts to access instances not passed as parameters or when decorated functions attempt
27
- a toccess class attributes instead of parameter instance attributes ."""
27
+ to access class attributes instead of parameter instance attributes."""
28
28
 
29
29
 
30
30
  class NotAFactError(ContractError):
@@ -152,16 +152,35 @@ class ASTProcessor[T: Callable]:
152
152
  raise ASTProcessingError(msg)
153
153
 
154
154
  # The source includes the entire line of code (e.g., assignment and condition() call)
155
- # We need to parse parentheses to extract just the lambda expression, handling any
156
- # nested parentheses in the lambda's body correctly
155
+ # We need to extract just the lambda expression, handling nested structures correctly
157
156
  source = self.source[lambda_start:]
157
+
158
+ # Track depth of various brackets to ensure we don't split inside valid nested structures apart from trailing
159
+ # arguments within the condition() call
158
160
  paren_level = 0
161
+ bracket_level = 0
162
+ brace_level = 0
163
+
159
164
  for i, char in enumerate(source):
160
165
  if char == "(":
161
166
  paren_level += 1
162
- elif char == ")" and paren_level > 0:
163
- paren_level -= 1
164
- elif char == ")" and paren_level == 0:
167
+ elif char == ")":
168
+ if paren_level > 0:
169
+ paren_level -= 1
170
+ elif paren_level == 0: # End of expression in a function call
171
+ return source[:i]
172
+ elif char == "[":
173
+ bracket_level += 1
174
+ elif char == "]":
175
+ if bracket_level > 0:
176
+ bracket_level -= 1
177
+ elif char == "{":
178
+ brace_level += 1
179
+ elif char == "}":
180
+ if brace_level > 0:
181
+ brace_level -= 1
182
+ # Only consider comma as a separator when not inside any brackets
183
+ elif char == "," and paren_level == 0 and bracket_level == 0 and brace_level == 0:
165
184
  return source[:i]
166
185
 
167
186
  return source
@@ -8,11 +8,11 @@ import re
8
8
  from abc import abstractmethod
9
9
  from dataclasses import dataclass, field
10
10
  from enum import Enum, auto
11
+ from functools import lru_cache
11
12
  from string import Formatter
12
13
  from typing import TYPE_CHECKING
13
14
 
14
15
  from langchain.prompts import ChatPromptTemplate
15
- from langchain_openai import ChatOpenAI
16
16
  from pydantic import BaseModel, Field
17
17
 
18
18
  from vulcan_core.actions import ASTProcessor
@@ -22,11 +22,16 @@ if TYPE_CHECKING: # pragma: no cover - not used at runtime
22
22
  from langchain_core.language_models import BaseChatModel
23
23
  from langchain_core.runnables import RunnableSerializable
24
24
 
25
+ import importlib.util
26
+ import logging
27
+
28
+ logger = logging.getLogger(__name__)
29
+
25
30
 
26
31
  @dataclass(frozen=True, slots=True)
27
32
  class Expression(DeclaresFacts):
28
33
  """
29
- Abstract base class for defining deferred logical expressions. It captures the assosciation of logic with Facts so
34
+ Abstract base class for defining deferred logical expressions. It captures the association of logic with Facts so
30
35
  that upon a Fact update, the logical expression can be selectively evaluated. It also provides a set of logical
31
36
  operators for combining conditions, resulting in a new CompoundCondition.
32
37
  """
@@ -58,7 +63,7 @@ class Expression(DeclaresFacts):
58
63
  @dataclass(frozen=True, slots=True)
59
64
  class Condition(FactHandler[ConditionCallable, bool], Expression):
60
65
  """
61
- A Condition is a container to defer logical epxressions against a supplied Fact. The expression can be inverted
66
+ A Condition is a container to defer logical expressions against a supplied Fact. The expression can be inverted
62
67
  using the `~` operator. Conditions also support the '&', '|', and '^' operators for combinatorial logic.
63
68
 
64
69
  Attributes:
@@ -182,6 +187,7 @@ class LiteralFormatter(Formatter):
182
187
  @dataclass(frozen=True, slots=True)
183
188
  class AICondition(Condition):
184
189
  chain: RunnableSerializable
190
+ model: BaseChatModel
185
191
  system_template: str
186
192
  inquiry_template: str
187
193
  func: None = field(init=False, default=None)
@@ -238,13 +244,23 @@ def ai_condition(model: BaseChatModel, inquiry: str) -> AICondition:
238
244
  )
239
245
  structured_model = model.with_structured_output(BooleanDecision)
240
246
  chain = prompt_template | structured_model
241
- return AICondition(chain=chain, system_template=system, inquiry_template=inquiry, facts=facts)
247
+ return AICondition(chain=chain, model=model, system_template=system, inquiry_template=inquiry, facts=facts)
242
248
 
243
249
 
244
- default_model = ChatOpenAI(model="gpt-4o-mini", temperature=0, max_tokens=100) # type: ignore[call-arg] - pyright can't see the args for some reason
250
+ @lru_cache(maxsize=1)
251
+ def _detect_default_model() -> BaseChatModel:
252
+ # TODO: Expand this to detect other providers
253
+ if importlib.util.find_spec("langchain_openai"):
254
+ from langchain_openai import ChatOpenAI
255
+
256
+ logger.debug("Using OpenAI as the default LLM model provider.")
257
+ return ChatOpenAI(model="gpt-4o-mini", temperature=0, max_tokens=100) # type: ignore[call-arg] - pyright can't see the args for some reason
258
+ else:
259
+ msg = "Unable to import a default LLM provider. Please install `vulcan_core` with the approriate extras package or specify your custom model explicitly."
260
+ raise ImportError(msg)
245
261
 
246
262
 
247
- def condition(func: ConditionCallable | str) -> Condition:
263
+ def condition(func: ConditionCallable | str, model: BaseChatModel | None = None) -> Condition:
248
264
  """
249
265
  Creates a Condition object from a lambda or function. It performs limited static analysis of the code to ensure
250
266
  proper usage and discover the facts/attributes accessed by the condition. This allows the rule engine to track
@@ -284,10 +300,14 @@ def condition(func: ConditionCallable | str) -> Condition:
284
300
  """
285
301
 
286
302
  if not isinstance(func, str):
303
+ # Logic condition assumed, ignore kwargs
287
304
  processed = ASTProcessor[ConditionCallable](func, condition, bool)
288
305
  return Condition(processed.facts, processed.func)
289
306
  else:
290
- return ai_condition(default_model, func)
307
+ # AI condition assumed
308
+ if not model:
309
+ model = _detect_default_model()
310
+ return ai_condition(model, func)
291
311
 
292
312
 
293
313
  # TODO: Create a convenience function for creating OnFactChanged conditions
@@ -9,6 +9,7 @@ from types import MappingProxyType
9
9
  from typing import TYPE_CHECKING
10
10
  from uuid import UUID, uuid4
11
11
 
12
+ from vulcan_core.ast_utils import NotAFactError
12
13
  from vulcan_core.models import DeclaresFacts, Fact
13
14
 
14
15
  if TYPE_CHECKING: # pragma: no cover - not used at runtime
@@ -104,6 +105,10 @@ class RuleEngine:
104
105
 
105
106
  if isinstance(fact, partial):
106
107
  fact_name = fact.func.__name__
108
+ fact_class = fact.func
109
+ if not issubclass(fact_class, Fact): # type: ignore
110
+ raise NotAFactError(fact_class)
111
+
107
112
  if fact_name in self._facts:
108
113
  self._facts[fact_name] |= fact
109
114
  else:
@@ -113,19 +118,24 @@ class RuleEngine:
113
118
  msg = f"Fact '{fact_name}' is missing and lacks sufficient defaults to create from partial: {fact}"
114
119
  raise InternalStateError(msg) from err
115
120
  else:
121
+ fact_class = type(fact)
122
+ if not issubclass(fact_class, Fact):
123
+ raise NotAFactError(fact_class)
124
+
116
125
  self._facts[type(fact).__name__] = fact
117
126
 
118
- def rule[T: Fact](self, *, name: str | None = None, when: Expression, then: Action, inverse: Action | None = None):
127
+ def rule[T: Fact](self, *, name: str | None = None, when: Expression, then: Action, inverse: Action | None = None) -> None:
119
128
  """
120
129
  Convenience method for adding a rule to the rule engine.
121
130
 
122
131
  Args:
123
- name (Optional[str]): The name of the rule. Defaults to None.
124
- when (Expression): The condition that triggers the rule.
125
- then (Action): The action to be executed when the condition is met.
126
- inverse (Optional[Action]): The action to be executed when the condition is not met. Defaults to None.
132
+ name (Optional[str]): The name of the rule. Defaults to None.
133
+ when (Expression): The condition that triggers the rule.
134
+ then (Action): The action to be executed when the condition is met.
135
+ inverse (Optional[Action]): The action to be executed when the condition is not met. Defaults to None.
127
136
 
128
- Returns: None
137
+ Returns:
138
+ None
129
139
  """
130
140
  rule = Rule(name, when, then, inverse)
131
141
 
@@ -120,7 +120,7 @@ class FactMetaclass(type):
120
120
 
121
121
  class Fact(ImmutableAttrAsDict, metaclass=FactMetaclass):
122
122
  """
123
- An abstract class that must be used define rule engine fact schemas and instantiate data into working memory. Facts
123
+ An abstract class that must be used to define rule engine fact schemas and instantiate data into working memory. Facts
124
124
  may be combined with partial facts of the same type using the `|` operator. This is useful for Actions that only
125
125
  need to update a portion of working memory.
126
126
 
@@ -133,8 +133,16 @@ class Fact(ImmutableAttrAsDict, metaclass=FactMetaclass):
133
133
  lefthand operand is created with the partial Fact's keywords applied.
134
134
  """
135
135
  if isinstance(other, Fact):
136
+ if type(self) is not type(other):
137
+ msg = f"Union operator disallowed for types {type(self).__name__} and {type(other).__name__}"
138
+ raise TypeError(msg)
139
+
136
140
  return other # type: ignore
137
141
  else:
142
+ if type(self) is not other.func:
143
+ msg = f"Union operator disallowed for types {type(self).__name__} and {other.func}"
144
+ raise TypeError(msg)
145
+
138
146
  new_fact = copy(self)
139
147
  for kw, value in other.keywords.items():
140
148
  object.__setattr__(new_fact, kw, value)
@@ -258,3 +266,6 @@ class RetrieverAdapter(Similarity):
258
266
 
259
267
  def __len__(self) -> int:
260
268
  raise NotImplementedError
269
+
270
+ def __str__(self) -> str:
271
+ return f"RetrieverAdapter(search_type={self.store.search_type})"
@@ -11,7 +11,7 @@ from typing import Any, NoReturn
11
11
 
12
12
  @dataclass(frozen=True)
13
13
  class WithContext:
14
- """Applys a context manager as a decorator.
14
+ """Applies a context manager as a decorator.
15
15
 
16
16
  @WithContext(suppress(Exception))
17
17
  def foo():
File without changes
File without changes