vulcan-core 1.0.0__py3-none-any.whl
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.
- vulcan_core/__init__.py +45 -0
- vulcan_core/actions.py +28 -0
- vulcan_core/ast_utils.py +296 -0
- vulcan_core/conditions.py +302 -0
- vulcan_core/engine.py +232 -0
- vulcan_core/models.py +260 -0
- vulcan_core/util.py +127 -0
- vulcan_core-1.0.0.dist-info/LICENSE +176 -0
- vulcan_core-1.0.0.dist-info/METADATA +90 -0
- vulcan_core-1.0.0.dist-info/NOTICE +8 -0
- vulcan_core-1.0.0.dist-info/RECORD +12 -0
- vulcan_core-1.0.0.dist-info/WHEEL +4 -0
vulcan_core/engine.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2025 Latchfield Technologies http://latchfield.com
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from functools import cached_property, partial
|
|
8
|
+
from types import MappingProxyType
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
from uuid import UUID, uuid4
|
|
11
|
+
|
|
12
|
+
from vulcan_core.models import DeclaresFacts, Fact
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING: # pragma: no cover - not used at runtime
|
|
15
|
+
from vulcan_core.actions import Action
|
|
16
|
+
from vulcan_core.conditions import Expression
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class InternalStateError(RuntimeError):
|
|
20
|
+
"""Raised when the internal state of the RuleEngine is invalid."""
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RecursionLimitError(RuntimeError):
|
|
24
|
+
"""Raised when the recursion limit is reached during rule evaluation."""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class Rule:
|
|
29
|
+
"""
|
|
30
|
+
Represents a rule with a condition and corresponding actions.
|
|
31
|
+
|
|
32
|
+
Attributes:
|
|
33
|
+
- id (UUID): A unique identifier for the rule, automatically generated.
|
|
34
|
+
- name (Optional[str]): The name of the rule.
|
|
35
|
+
- when (Expression): The condition that triggers the rule.
|
|
36
|
+
- then (Action): The action to be executed when the condition is met.
|
|
37
|
+
- inverse (Optional[Action]): An optional action to be executed when the condition is not met.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
id: UUID = field(default_factory=uuid4, init=False)
|
|
41
|
+
name: str | None
|
|
42
|
+
when: Expression
|
|
43
|
+
then: Action
|
|
44
|
+
inverse: Action | None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
# TODO: Look into support for langchain operators and lang graph integration
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(kw_only=True)
|
|
51
|
+
class RuleEngine:
|
|
52
|
+
"""
|
|
53
|
+
RuleEngine is a class that manages the evaluation of rules based on a set of facts. It allows for the addition of rules,
|
|
54
|
+
updating of facts, and cascading evaluation of rules.
|
|
55
|
+
|
|
56
|
+
Attributes:
|
|
57
|
+
enabled (bool): Indicates whether the rule engine is enabled.
|
|
58
|
+
recusion_limit (int): The maximum number of recursive evaluations allowed.
|
|
59
|
+
facts (dict[type[Fact], Fact]): A dictionary to store facts with their types as keys.
|
|
60
|
+
rules (dict[str, list[Rule]]): A dictionary to store rules associated with fact strings.
|
|
61
|
+
|
|
62
|
+
Methods:
|
|
63
|
+
rule(self, *, name: str | None = None, when: LogicEvaluator, then: BaseAction, inverse: BaseAction | None = None): Adds a rule to the rule engine.
|
|
64
|
+
update_facts(self, fact: tuple[Fact | partial[Fact], ...] | partial[Fact] | Fact) -> Iterator[str]: Updates the facts in the working memory.
|
|
65
|
+
evaluate(self): Evaluates the rules based on the current facts in working memory.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
enabled: bool = False
|
|
69
|
+
recusion_limit: int = 10
|
|
70
|
+
_facts: dict[str, Fact] = field(default_factory=dict, init=False)
|
|
71
|
+
_rules: dict[str, list[Rule]] = field(default_factory=dict, init=False)
|
|
72
|
+
|
|
73
|
+
@cached_property
|
|
74
|
+
def facts(self) -> MappingProxyType[str, Fact]:
|
|
75
|
+
return MappingProxyType(self._facts)
|
|
76
|
+
|
|
77
|
+
@cached_property
|
|
78
|
+
def rules(self) -> MappingProxyType[str, list[Rule]]:
|
|
79
|
+
return MappingProxyType(self._rules)
|
|
80
|
+
|
|
81
|
+
def __getitem__[T: Fact](self, key: type[T]) -> T:
|
|
82
|
+
"""
|
|
83
|
+
Retrieves a fact from the working memory.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
key (type[Fact]): The type of the fact to retrieve.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
T: The fact instance of the specified type.
|
|
90
|
+
"""
|
|
91
|
+
return self._facts[key.__name__] # type: ignore
|
|
92
|
+
|
|
93
|
+
def fact(self, fact: Fact | partial[Fact]):
|
|
94
|
+
"""
|
|
95
|
+
Updates the working memory with a new fact or merges a partial fact.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
fact (Union[Fact, partial[Fact]]): The fact instance or partial fact to update the working memory with.
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
InternalStateError: If a partial fact cannot be instantiated due to missing required fields
|
|
102
|
+
"""
|
|
103
|
+
# TODO: Figure out how to track only fact attributes that have changed, and fire on affected rules
|
|
104
|
+
|
|
105
|
+
if isinstance(fact, partial):
|
|
106
|
+
fact_name = fact.func.__name__
|
|
107
|
+
if fact_name in self._facts:
|
|
108
|
+
self._facts[fact_name] |= fact
|
|
109
|
+
else:
|
|
110
|
+
try:
|
|
111
|
+
self._facts[fact_name] = fact()
|
|
112
|
+
except TypeError as err:
|
|
113
|
+
msg = f"Fact '{fact_name}' is missing and lacks sufficient defaults to create from partial: {fact}"
|
|
114
|
+
raise InternalStateError(msg) from err
|
|
115
|
+
else:
|
|
116
|
+
self._facts[type(fact).__name__] = fact
|
|
117
|
+
|
|
118
|
+
def rule[T: Fact](self, *, name: str | None = None, when: Expression, then: Action, inverse: Action | None = None):
|
|
119
|
+
"""
|
|
120
|
+
Convenience method for adding a rule to the rule engine.
|
|
121
|
+
|
|
122
|
+
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.
|
|
127
|
+
|
|
128
|
+
Returns: None
|
|
129
|
+
"""
|
|
130
|
+
rule = Rule(name, when, then, inverse)
|
|
131
|
+
|
|
132
|
+
# TODO: Add automatic inverse option?
|
|
133
|
+
|
|
134
|
+
# Update the facts to rule mapping
|
|
135
|
+
for fact_str in when.facts:
|
|
136
|
+
if fact_str in self._rules:
|
|
137
|
+
self._rules[fact_str].append(rule)
|
|
138
|
+
else:
|
|
139
|
+
self._rules[fact_str] = [rule]
|
|
140
|
+
|
|
141
|
+
def _update_facts(self, fact: tuple[Fact | partial[Fact], ...] | partial[Fact] | Fact) -> list[str]:
|
|
142
|
+
"""
|
|
143
|
+
Updates the fact in the facts dictionary. If the provided fact is an instance of Fact, it updates the dictionary
|
|
144
|
+
with the type of the fact as the key. If the provided fact is a partial function, it updates the dictionary with
|
|
145
|
+
the function of the partial as the key.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
fact (tuple[Fact | partial[Fact], ...] | partial[Fact] | Fact): The fact(s) to be updated, either as an instance of Fact, a partial function, or a tuple of either.
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Iterator[str]: An iterator over the fact strings of the updated facts.
|
|
152
|
+
"""
|
|
153
|
+
facts = fact if isinstance(fact, tuple) else (fact,)
|
|
154
|
+
updated = []
|
|
155
|
+
|
|
156
|
+
for f in facts:
|
|
157
|
+
self.fact(f)
|
|
158
|
+
|
|
159
|
+
# Track which attributes were updated
|
|
160
|
+
if isinstance(f, partial):
|
|
161
|
+
fact_name = f.func.__name__
|
|
162
|
+
attrs = f.keywords
|
|
163
|
+
else:
|
|
164
|
+
fact_name = f.__class__.__name__
|
|
165
|
+
attrs = vars(f)
|
|
166
|
+
|
|
167
|
+
updated.extend([f"{fact_name}.{attr}" for attr in attrs])
|
|
168
|
+
|
|
169
|
+
return updated
|
|
170
|
+
|
|
171
|
+
def _resolve_facts(self, declared: DeclaresFacts) -> list[Fact]:
|
|
172
|
+
# Deduplicate the fact strings and retrieve unique fact instances
|
|
173
|
+
keys = {key.split(".")[0]: key for key in declared.facts}.values()
|
|
174
|
+
return [self._facts[key.split(".")[0]] for key in keys]
|
|
175
|
+
|
|
176
|
+
def evaluate(self, fact: Fact | partial[Fact] | None = None):
|
|
177
|
+
"""
|
|
178
|
+
Cascading evaluation of rules based on the facts in working memory.
|
|
179
|
+
|
|
180
|
+
If provided a fact, will update and evaluate immediately. Otherwise all rules will be evaluated.
|
|
181
|
+
"""
|
|
182
|
+
fired_rules: set[UUID] = set()
|
|
183
|
+
consequence: set[str] = set()
|
|
184
|
+
|
|
185
|
+
# TODO: Create an internal consistency check to determine if all referenced Facts are present?
|
|
186
|
+
|
|
187
|
+
# TODO: detect cycles in graph before executing
|
|
188
|
+
# Move to a separate lifecycle step?
|
|
189
|
+
# Provide option for handling
|
|
190
|
+
|
|
191
|
+
# TODO: Check whether fact attributes have actually changed, and only fire rules that are affected
|
|
192
|
+
if fact:
|
|
193
|
+
scope = self._update_facts(fact)
|
|
194
|
+
else:
|
|
195
|
+
# By default, evaluate all facts
|
|
196
|
+
fact_list = self._facts.values()
|
|
197
|
+
scope = {f"{fact.__class__.__name__}.{attr}" for fact in fact_list for attr in vars(fact)}
|
|
198
|
+
|
|
199
|
+
# Iterate over the rules until the recusion limit is reached or no new rules are fired
|
|
200
|
+
for iteration in range(self.recusion_limit + 1):
|
|
201
|
+
if iteration == self.recusion_limit:
|
|
202
|
+
msg = f"Recursion limit of {self.recusion_limit} reached"
|
|
203
|
+
raise RecursionLimitError(msg)
|
|
204
|
+
|
|
205
|
+
for fact_str, rules in self._rules.items():
|
|
206
|
+
if fact_str in scope:
|
|
207
|
+
for rule in rules:
|
|
208
|
+
# Skip if we already evaluated the rule this iteration
|
|
209
|
+
if rule.id in fired_rules:
|
|
210
|
+
continue
|
|
211
|
+
fired_rules.add(rule.id)
|
|
212
|
+
|
|
213
|
+
# Evaluate the rule's 'when' and determine which action to invoke
|
|
214
|
+
action = None
|
|
215
|
+
if rule.when(*self._resolve_facts(rule.when)):
|
|
216
|
+
action = rule.then
|
|
217
|
+
elif rule.inverse:
|
|
218
|
+
action = rule.inverse
|
|
219
|
+
|
|
220
|
+
if action:
|
|
221
|
+
# Update the facts and track consequences to fire subsequent rules
|
|
222
|
+
result = action(*self._resolve_facts(action))
|
|
223
|
+
facts = self._update_facts(result)
|
|
224
|
+
consequence.update(facts)
|
|
225
|
+
|
|
226
|
+
# If rules updated some facts, prepare for the next iteration
|
|
227
|
+
if consequence:
|
|
228
|
+
scope = consequence
|
|
229
|
+
consequence = set()
|
|
230
|
+
fired_rules.clear()
|
|
231
|
+
else:
|
|
232
|
+
break
|
vulcan_core/models.py
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2025 Latchfield Technologies http://latchfield.com
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from collections.abc import Callable, Iterator, Mapping
|
|
8
|
+
from copy import copy
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from enum import StrEnum, auto
|
|
11
|
+
from typing import (
|
|
12
|
+
TYPE_CHECKING,
|
|
13
|
+
Any,
|
|
14
|
+
Protocol,
|
|
15
|
+
Self,
|
|
16
|
+
dataclass_transform,
|
|
17
|
+
runtime_checkable,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from langchain.schema import Document
|
|
21
|
+
|
|
22
|
+
from vulcan_core.util import is_private
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING: # pragma: no cover - not used at runtime
|
|
25
|
+
from functools import partial
|
|
26
|
+
|
|
27
|
+
from langchain_core.vectorstores import VectorStoreRetriever
|
|
28
|
+
|
|
29
|
+
type ActionReturn = tuple[partial[Fact] | Fact, ...] | partial[Fact] | Fact
|
|
30
|
+
type ActionCallable = Callable[..., ActionReturn]
|
|
31
|
+
type ConditionCallable = Callable[..., bool]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# TODO: Consolidate with AttrDict, and/or figure out how to extende from Mapping
|
|
35
|
+
class ImmutableAttrAsDict:
|
|
36
|
+
"""
|
|
37
|
+
ImmutableAttrAsDict is an abstract base class that provides dictionary-like access to its attributes.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __getitem__(self, key: str) -> Any:
|
|
41
|
+
try:
|
|
42
|
+
return getattr(self, self.validate(key))
|
|
43
|
+
except KeyError:
|
|
44
|
+
if hasattr(self, "__missing__"):
|
|
45
|
+
return self.__missing__(key) # type: ignore
|
|
46
|
+
else:
|
|
47
|
+
raise
|
|
48
|
+
|
|
49
|
+
def __contains__(self, key: str) -> bool:
|
|
50
|
+
return hasattr(self, self.validate(key))
|
|
51
|
+
|
|
52
|
+
def __iter__(self) -> Iterator[str]:
|
|
53
|
+
return (key for key in self.__annotations__ if not is_private(key))
|
|
54
|
+
|
|
55
|
+
def __len__(self) -> int:
|
|
56
|
+
return sum(1 for _ in self)
|
|
57
|
+
|
|
58
|
+
def validate(self, key: str) -> str:
|
|
59
|
+
if is_private(key):
|
|
60
|
+
msg = f"Access denied to private attribute: {key}"
|
|
61
|
+
raise KeyError(msg)
|
|
62
|
+
|
|
63
|
+
if key not in self.__annotations__:
|
|
64
|
+
raise KeyError(key)
|
|
65
|
+
|
|
66
|
+
return key
|
|
67
|
+
|
|
68
|
+
def __init__(self):
|
|
69
|
+
if type(self) is ImmutableAttrAsDict:
|
|
70
|
+
msg = f"{ImmutableAttrAsDict.__name__} is an abstract class that can not be directly instantiated."
|
|
71
|
+
raise TypeError(msg)
|
|
72
|
+
|
|
73
|
+
def __reversed__(self) -> Iterator[str]:
|
|
74
|
+
return reversed(list(self))
|
|
75
|
+
|
|
76
|
+
def __or__(self, other: dict) -> dict:
|
|
77
|
+
return dict(self) | other
|
|
78
|
+
|
|
79
|
+
def keys(self) -> list[str]:
|
|
80
|
+
return list(self)
|
|
81
|
+
|
|
82
|
+
def values(self) -> list[Any]:
|
|
83
|
+
return [getattr(self, key) for key in self]
|
|
84
|
+
|
|
85
|
+
def items(self) -> list[tuple[str, Any]]:
|
|
86
|
+
return [(key, getattr(self, key)) for key in self]
|
|
87
|
+
|
|
88
|
+
def get(self, key: str, default: Any = None):
|
|
89
|
+
return getattr(self, self.validate(key), default)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass_transform(kw_only_default=True, frozen_default=True)
|
|
93
|
+
class FactMetaclass(type):
|
|
94
|
+
"""
|
|
95
|
+
FactMetaclass is a metaclass that modifies the creation of new classes to automatically
|
|
96
|
+
apply the `dataclass` decorator with `kw_only=True` and `frozen=True` options.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
def __new__(cls, name: str, bases: tuple[type], class_dict: dict[str, Any], **kwargs: Any):
|
|
100
|
+
self = super().__new__(cls, name, bases, class_dict, **kwargs)
|
|
101
|
+
return dataclass(kw_only=True, frozen=True)(self)
|
|
102
|
+
|
|
103
|
+
def _is_dataclass_instance(cls) -> bool:
|
|
104
|
+
"""Determine if this is a dataclass instance by looking for __dataclass_fields__"""
|
|
105
|
+
return "__dataclass_fields__" not in super().__getattribute__("__dict__")
|
|
106
|
+
|
|
107
|
+
# TODO: Implement a context manager to allow access to the default class values
|
|
108
|
+
# BUG: This causes pylance to not report missing attributes, we need a different way to handle f strings... maybe
|
|
109
|
+
# the __format__ method?
|
|
110
|
+
def __getattribute__(cls, name):
|
|
111
|
+
"""
|
|
112
|
+
Returns a {templated} representation of the Fact's public attributes for deferred use in fstrings. This is
|
|
113
|
+
useful in rule clauses so that IDE autocomplete can be used in fstrings while deferring evaluation of
|
|
114
|
+
the content."""
|
|
115
|
+
if name.startswith("_") or cls._is_dataclass_instance():
|
|
116
|
+
return super().__getattribute__(name)
|
|
117
|
+
else:
|
|
118
|
+
return f"{{{cls.__name__}.{name}}}"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
class Fact(ImmutableAttrAsDict, metaclass=FactMetaclass):
|
|
122
|
+
"""
|
|
123
|
+
An abstract class that must be used define rule engine fact schemas and instantiate data into working memory. Facts
|
|
124
|
+
may be combined with partial facts of the same type using the `|` operator. This is useful for Actions that only
|
|
125
|
+
need to update a portion of working memory.
|
|
126
|
+
|
|
127
|
+
Example: `new_fact = Inventory(apples=1) | partial(Inventory, oranges=2)`
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def __or__(self, other: partial[Self] | Self) -> Self:
|
|
131
|
+
"""
|
|
132
|
+
If the right hand operand is a Fact, it is returned as-is. However, if it is a partial Fact, a copy of the
|
|
133
|
+
lefthand operand is created with the partial Fact's keywords applied.
|
|
134
|
+
"""
|
|
135
|
+
if isinstance(other, Fact):
|
|
136
|
+
return other # type: ignore
|
|
137
|
+
else:
|
|
138
|
+
new_fact = copy(self)
|
|
139
|
+
for kw, value in other.keywords.items():
|
|
140
|
+
object.__setattr__(new_fact, kw, value)
|
|
141
|
+
return new_fact # type: ignore
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True)
|
|
145
|
+
class DeclaresFacts(ABC):
|
|
146
|
+
facts: tuple[str, ...]
|
|
147
|
+
# TODO differentiate bettwen facts consumed vs produced for better tracking/diagnostics
|
|
148
|
+
# Will probably be needed to detecte cycles in the graph
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True)
|
|
152
|
+
class FactHandler[T: Callable, R: Any](ABC):
|
|
153
|
+
func: T
|
|
154
|
+
|
|
155
|
+
@abstractmethod
|
|
156
|
+
def __call__(self, *args: Fact) -> R: ...
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@runtime_checkable
|
|
160
|
+
class HasSource(Protocol):
|
|
161
|
+
__source__: str
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class ChunkingStrategy(StrEnum):
|
|
165
|
+
SENTENCE = auto()
|
|
166
|
+
PARAGRAPH = auto()
|
|
167
|
+
PAGE = auto()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@dataclass(kw_only=True, slots=True)
|
|
171
|
+
class Similarity(Mapping[str, list[tuple[str, float]]]):
|
|
172
|
+
# TODO: Figure out how to cache vectors / and results?
|
|
173
|
+
|
|
174
|
+
@abstractmethod
|
|
175
|
+
def __getitem__(self, key: str) -> list[str]:
|
|
176
|
+
"""Vectorizes key and performs similarity search returning a list of matching."""
|
|
177
|
+
raise NotImplementedError
|
|
178
|
+
|
|
179
|
+
@abstractmethod
|
|
180
|
+
def __contains__(self, key: str) -> bool:
|
|
181
|
+
"""Vectorizes key and performs similarity search returning a boolean if there is at least one match."""
|
|
182
|
+
raise NotImplementedError
|
|
183
|
+
|
|
184
|
+
@abstractmethod
|
|
185
|
+
def __iadd__(self, value: str) -> Self:
|
|
186
|
+
raise NotImplementedError
|
|
187
|
+
|
|
188
|
+
@abstractmethod
|
|
189
|
+
def __iter__(self) -> str:
|
|
190
|
+
raise NotImplementedError
|
|
191
|
+
|
|
192
|
+
@abstractmethod
|
|
193
|
+
def __len__(self) -> int:
|
|
194
|
+
raise NotImplementedError
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class ProxyInitializationError(Exception):
|
|
198
|
+
"""Raised when a Proxy class is used without the proxy being initialized."""
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(kw_only=True, slots=True)
|
|
202
|
+
class ProxyLazyLookup(Similarity):
|
|
203
|
+
_proxy: Similarity | None = None
|
|
204
|
+
|
|
205
|
+
@property
|
|
206
|
+
def proxy(self) -> Similarity:
|
|
207
|
+
if self._proxy:
|
|
208
|
+
return self
|
|
209
|
+
else:
|
|
210
|
+
msg = "The `proxy` attribute must be set before the class instance can be used."
|
|
211
|
+
raise ProxyInitializationError(msg)
|
|
212
|
+
|
|
213
|
+
@proxy.setter
|
|
214
|
+
def proxy(self, value: Similarity) -> None:
|
|
215
|
+
if not self._proxy:
|
|
216
|
+
self._proxy = value
|
|
217
|
+
else:
|
|
218
|
+
msg = "The `proxy` attribute can only be initialized once."
|
|
219
|
+
raise ProxyInitializationError(msg)
|
|
220
|
+
|
|
221
|
+
def __getitem__(self, key: str) -> list[str]:
|
|
222
|
+
return self.proxy[key]
|
|
223
|
+
|
|
224
|
+
def __contains__(self, key: str) -> bool:
|
|
225
|
+
raise NotImplementedError
|
|
226
|
+
|
|
227
|
+
def __iadd__(self, value: str) -> Self:
|
|
228
|
+
self.proxy += value
|
|
229
|
+
return self
|
|
230
|
+
|
|
231
|
+
def __iter__(self) -> str:
|
|
232
|
+
raise NotImplementedError
|
|
233
|
+
|
|
234
|
+
def __len__(self) -> int:
|
|
235
|
+
raise NotImplementedError
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass(kw_only=True, slots=True)
|
|
239
|
+
class RetrieverAdapter(Similarity):
|
|
240
|
+
"""A lazy lookup that uses the Chroma vector store to perform similarity searches using OpenAI embeddings."""
|
|
241
|
+
|
|
242
|
+
store: VectorStoreRetriever
|
|
243
|
+
|
|
244
|
+
def __getitem__(self, key: str) -> list[str]:
|
|
245
|
+
"""Vectorizes key and performs similarity search returning a list of matching content."""
|
|
246
|
+
return [doc.page_content for doc in self.store.invoke(key)]
|
|
247
|
+
|
|
248
|
+
def __contains__(self, key: str) -> bool:
|
|
249
|
+
"""Vectorizes key and performs similarity search returning a boolean if there is at least one match."""
|
|
250
|
+
raise NotImplementedError
|
|
251
|
+
|
|
252
|
+
def __iadd__(self, value: str) -> Self:
|
|
253
|
+
self.store.add_documents([Document(value)])
|
|
254
|
+
return self
|
|
255
|
+
|
|
256
|
+
def __iter__(self) -> str:
|
|
257
|
+
raise NotImplementedError
|
|
258
|
+
|
|
259
|
+
def __len__(self) -> int:
|
|
260
|
+
raise NotImplementedError
|
vulcan_core/util.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2025 Latchfield Technologies http://latchfield.com
|
|
3
|
+
|
|
4
|
+
import functools
|
|
5
|
+
from collections.abc import Callable, Iterator
|
|
6
|
+
from contextlib import AbstractContextManager
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from functools import wraps
|
|
9
|
+
from typing import Any, NoReturn
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class WithContext:
|
|
14
|
+
"""Applys a context manager as a decorator.
|
|
15
|
+
|
|
16
|
+
@WithContext(suppress(Exception))
|
|
17
|
+
def foo():
|
|
18
|
+
raise Exception("Some Exception")
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
context: AbstractContextManager
|
|
22
|
+
|
|
23
|
+
def __call__(self, func):
|
|
24
|
+
@wraps(func)
|
|
25
|
+
def wrapper(*args, **kwargs):
|
|
26
|
+
with self.context:
|
|
27
|
+
return func(*args, **kwargs)
|
|
28
|
+
|
|
29
|
+
return wrapper
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def not_implemented(func) -> Callable:
|
|
33
|
+
@functools.wraps(func)
|
|
34
|
+
def wrapper(*args, **kwargs) -> NoReturn:
|
|
35
|
+
msg = f"{func.__name__} is not implemented."
|
|
36
|
+
raise NotImplementedError(msg)
|
|
37
|
+
|
|
38
|
+
return wrapper
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def is_private(key: str) -> bool:
|
|
42
|
+
return key.startswith("_")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class AttrDict(dict):
|
|
46
|
+
def validate(self, key: str) -> str:
|
|
47
|
+
if is_private(key):
|
|
48
|
+
msg = f"Access denied to private attribute: {key}"
|
|
49
|
+
raise KeyError(msg)
|
|
50
|
+
|
|
51
|
+
if key not in self.__annotations__:
|
|
52
|
+
raise KeyError(key)
|
|
53
|
+
|
|
54
|
+
return key
|
|
55
|
+
|
|
56
|
+
def __init__(self):
|
|
57
|
+
if type(self) is AttrDict:
|
|
58
|
+
msg = f"{AttrDict.__name__} is an abstract class that can not be directly instantiated."
|
|
59
|
+
raise TypeError(msg)
|
|
60
|
+
|
|
61
|
+
def __getitem__(self, key: str) -> Any:
|
|
62
|
+
try:
|
|
63
|
+
return getattr(self, self.validate(key))
|
|
64
|
+
except KeyError:
|
|
65
|
+
if hasattr(self, "__missing__"):
|
|
66
|
+
return self.__missing__(key) # type: ignore
|
|
67
|
+
else:
|
|
68
|
+
raise
|
|
69
|
+
|
|
70
|
+
def __setitem__(self, key: str, value: Any) -> None:
|
|
71
|
+
setattr(self, self.validate(key), value)
|
|
72
|
+
|
|
73
|
+
def __iter__(self) -> Iterator[str]:
|
|
74
|
+
return (key for key in self.__annotations__ if not is_private(key))
|
|
75
|
+
|
|
76
|
+
def __reversed__(self) -> Iterator[str]:
|
|
77
|
+
return reversed(list(self))
|
|
78
|
+
|
|
79
|
+
def __len__(self) -> int:
|
|
80
|
+
return sum(1 for _ in self)
|
|
81
|
+
|
|
82
|
+
def __contains__(self, key: str) -> bool:
|
|
83
|
+
return hasattr(self, self.validate(key))
|
|
84
|
+
|
|
85
|
+
def __or__(self, other: dict) -> dict:
|
|
86
|
+
return dict(self) | other
|
|
87
|
+
|
|
88
|
+
def __repr__(self) -> str:
|
|
89
|
+
return repr(dict(self))
|
|
90
|
+
|
|
91
|
+
def keys(self) -> list[str]:
|
|
92
|
+
return list(self)
|
|
93
|
+
|
|
94
|
+
def values(self) -> list[Any]:
|
|
95
|
+
return [getattr(self, key) for key in self]
|
|
96
|
+
|
|
97
|
+
def items(self) -> list[tuple[str, Any]]:
|
|
98
|
+
return [(key, getattr(self, key)) for key in self]
|
|
99
|
+
|
|
100
|
+
def get(self, key: str, default: Any = None):
|
|
101
|
+
return getattr(self, self.validate(key), default)
|
|
102
|
+
|
|
103
|
+
def setdefault(self, key: str, default: Any = None) -> Any:
|
|
104
|
+
if key not in self:
|
|
105
|
+
self[key] = default
|
|
106
|
+
return self[key]
|
|
107
|
+
|
|
108
|
+
@not_implemented
|
|
109
|
+
def __delitem__(self, key: str) -> NoReturn: ...
|
|
110
|
+
|
|
111
|
+
@not_implemented
|
|
112
|
+
def __ior__(self, other: dict[str, Any]) -> NoReturn: ...
|
|
113
|
+
|
|
114
|
+
@not_implemented
|
|
115
|
+
def clear(self) -> NoReturn: ...
|
|
116
|
+
|
|
117
|
+
@not_implemented
|
|
118
|
+
def copy(self) -> NoReturn: ...
|
|
119
|
+
|
|
120
|
+
@not_implemented
|
|
121
|
+
def pop(self, key: str, defaul: Any = None) -> NoReturn: ...
|
|
122
|
+
|
|
123
|
+
@not_implemented
|
|
124
|
+
def popitem(self) -> NoReturn: ...
|
|
125
|
+
|
|
126
|
+
@not_implemented
|
|
127
|
+
def update(self, *args, **kwargs) -> NoReturn: ...
|