vulcan-core 1.1.4__py3-none-any.whl → 1.2.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/actions.py +3 -0
- vulcan_core/ast_utils.py +42 -49
- vulcan_core/conditions.py +134 -67
- vulcan_core/engine.py +63 -18
- vulcan_core/models.py +2 -2
- vulcan_core/reporting.py +595 -0
- {vulcan_core-1.1.4.dist-info → vulcan_core-1.2.0.dist-info}/METADATA +4 -3
- vulcan_core-1.2.0.dist-info/RECORD +13 -0
- vulcan_core-1.1.4.dist-info/RECORD +0 -12
- {vulcan_core-1.1.4.dist-info → vulcan_core-1.2.0.dist-info}/LICENSE +0 -0
- {vulcan_core-1.1.4.dist-info → vulcan_core-1.2.0.dist-info}/NOTICE +0 -0
- {vulcan_core-1.1.4.dist-info → vulcan_core-1.2.0.dist-info}/WHEEL +0 -0
vulcan_core/reporting.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
# Copyright 2025 Latchfield Technologies http://latchfield.com
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import UTC, datetime
|
|
9
|
+
from functools import partial
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
import yaml
|
|
13
|
+
|
|
14
|
+
from vulcan_core.conditions import AICondition, CompoundCondition, Condition
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING: # pragma: no cover - not used at runtime
|
|
17
|
+
from collections.abc import Mapping
|
|
18
|
+
from vulcan_core.conditions import Expression
|
|
19
|
+
from vulcan_core.engine import Rule
|
|
20
|
+
from vulcan_core.models import ActionReturn, Fact
|
|
21
|
+
|
|
22
|
+
Primitive = int | float | bool | str | bytes | complex
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ReportGenerationError(RuntimeError):
|
|
26
|
+
"""Raised when there is an error generating a report from the rule engine."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class StopWatchError(RuntimeError):
|
|
30
|
+
"""Raised when there is an error with the stopwatch operations."""
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(slots=True)
|
|
34
|
+
class StopWatch:
|
|
35
|
+
"""A simple stopwatch for timing operations."""
|
|
36
|
+
|
|
37
|
+
_duration: float | None = field(default=None, init=False)
|
|
38
|
+
_timestamp: datetime | None = field(default=None, init=False)
|
|
39
|
+
_start_time: float | None = field(default=None, init=False)
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def duration(self) -> float:
|
|
43
|
+
"""Get the duration between start and stopwatch in seconds."""
|
|
44
|
+
if self._duration is None:
|
|
45
|
+
msg = "No stopwatch measurement. Call start() then stop() before accessing duration."
|
|
46
|
+
raise StopWatchError(msg)
|
|
47
|
+
|
|
48
|
+
return self._duration
|
|
49
|
+
|
|
50
|
+
@property
|
|
51
|
+
def timestamp(self) -> datetime:
|
|
52
|
+
"""Get the timestamp of when the stopwatch was started."""
|
|
53
|
+
if self._timestamp is None:
|
|
54
|
+
msg = "Stopwatch not started. Call start() first."
|
|
55
|
+
raise StopWatchError(msg)
|
|
56
|
+
|
|
57
|
+
return self._timestamp
|
|
58
|
+
|
|
59
|
+
def start(self) -> None:
|
|
60
|
+
"""Start or restart the stopwatch."""
|
|
61
|
+
self._start_time = time.time()
|
|
62
|
+
self._timestamp = datetime.now(UTC)
|
|
63
|
+
self._duration = None
|
|
64
|
+
|
|
65
|
+
def stop(self) -> None:
|
|
66
|
+
"""Stop the stopwatch and calculate duration."""
|
|
67
|
+
if self._start_time is None:
|
|
68
|
+
msg = "Stopwatch not started. Call start() first."
|
|
69
|
+
raise StopWatchError(msg)
|
|
70
|
+
|
|
71
|
+
self._duration = time.time() - self._start_time
|
|
72
|
+
self._start_time = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True)
|
|
76
|
+
class RuleMatch:
|
|
77
|
+
"""Represents a single rule match within an iteration."""
|
|
78
|
+
|
|
79
|
+
rule: str # Format: "id:name"
|
|
80
|
+
timestamp: datetime
|
|
81
|
+
elapsed: float # seconds with millisecond precision
|
|
82
|
+
evaluation: str # String representation of the evaluation
|
|
83
|
+
consequences: tuple[RuleConsequence, ...] = field(default_factory=tuple)
|
|
84
|
+
warnings: tuple[str, ...] = field(default_factory=tuple)
|
|
85
|
+
context: tuple[RuleContext, ...] = field(default_factory=tuple)
|
|
86
|
+
rationale: str | None = None
|
|
87
|
+
|
|
88
|
+
def to_dict(self) -> dict[str, Any]:
|
|
89
|
+
"""Convert to dictionary for YAML serialization."""
|
|
90
|
+
# Format timestamp as 'YYYY-MM-DDTHH:MM:SS(.ffffff)Z' (no offset)
|
|
91
|
+
ts = self.timestamp
|
|
92
|
+
if ts.tzinfo is not None:
|
|
93
|
+
ts = ts.astimezone(UTC).replace(tzinfo=None)
|
|
94
|
+
result = {
|
|
95
|
+
"rule": self.rule,
|
|
96
|
+
"timestamp": ts.isoformat() + "Z",
|
|
97
|
+
"elapsed": round(self.elapsed, 3),
|
|
98
|
+
"evaluation": self.evaluation,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
# Handle consequences
|
|
102
|
+
if self.consequences:
|
|
103
|
+
consequences_dict = {}
|
|
104
|
+
for consequence in self.consequences:
|
|
105
|
+
consequences_dict.update(consequence.to_dict())
|
|
106
|
+
result["consequences"] = consequences_dict
|
|
107
|
+
else:
|
|
108
|
+
result["consequences"] = None
|
|
109
|
+
|
|
110
|
+
# Add optional fields only if they have content
|
|
111
|
+
if self.warnings:
|
|
112
|
+
result["warnings"] = list(self.warnings)
|
|
113
|
+
|
|
114
|
+
if self.context:
|
|
115
|
+
context_list = [ctx.to_dict() for ctx in self.context]
|
|
116
|
+
result["context"] = context_list
|
|
117
|
+
|
|
118
|
+
if self.rationale:
|
|
119
|
+
result["rationale"] = self.rationale
|
|
120
|
+
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True, slots=True)
|
|
125
|
+
class FactRecord:
|
|
126
|
+
"""Tracks fact attribute changes within an iteration."""
|
|
127
|
+
|
|
128
|
+
rule_id: str
|
|
129
|
+
rule_name: str
|
|
130
|
+
value: Any
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True, slots=True)
|
|
134
|
+
class Iteration:
|
|
135
|
+
"""Tracks iteration data during execution and provides serialization for reporting."""
|
|
136
|
+
|
|
137
|
+
id: int = field(default=-1)
|
|
138
|
+
stopwatch: StopWatch = field(default_factory=StopWatch, init=False)
|
|
139
|
+
matched_rules: list[RuleMatch] = field(default_factory=list, init=False)
|
|
140
|
+
updated_facts: dict[str, FactRecord] = field(default_factory=dict, init=False)
|
|
141
|
+
|
|
142
|
+
def __post_init__(self):
|
|
143
|
+
self.stopwatch.start()
|
|
144
|
+
|
|
145
|
+
def to_dict(self) -> dict[str, Any]:
|
|
146
|
+
"""Convert to dictionary for YAML serialization."""
|
|
147
|
+
# Format timestamp as 'YYYY-MM-DDTHH:MM:SS(.ffffff)Z' (no offset)
|
|
148
|
+
ts = self.stopwatch.timestamp
|
|
149
|
+
if ts.tzinfo is not None:
|
|
150
|
+
ts = ts.astimezone(UTC).replace(tzinfo=None)
|
|
151
|
+
return {
|
|
152
|
+
"id": self.id,
|
|
153
|
+
"timestamp": ts.isoformat() + "Z",
|
|
154
|
+
"elapsed": round(self.stopwatch.duration, 3),
|
|
155
|
+
"matches": [match.to_dict() for match in self.matched_rules],
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True, slots=True)
|
|
160
|
+
class RuleConsequence:
|
|
161
|
+
"""Represents a consequences of a rule action."""
|
|
162
|
+
|
|
163
|
+
fact_name: str
|
|
164
|
+
attribute_name: str
|
|
165
|
+
value: Primitive | None = None
|
|
166
|
+
|
|
167
|
+
def to_dict(self) -> dict[str, Primitive | None]:
|
|
168
|
+
"""Convert to dictionary for YAML serialization."""
|
|
169
|
+
|
|
170
|
+
if self.attribute_name:
|
|
171
|
+
return {f"{self.fact_name}.{self.attribute_name}": self.value}
|
|
172
|
+
else:
|
|
173
|
+
return {self.fact_name: self.value}
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
@dataclass(frozen=True, slots=True)
|
|
177
|
+
class RuleContext:
|
|
178
|
+
"""Represents context information for values referenced in conditions."""
|
|
179
|
+
|
|
180
|
+
fact_attribute: str
|
|
181
|
+
value: str
|
|
182
|
+
|
|
183
|
+
def to_dict(self) -> dict[str, str]:
|
|
184
|
+
"""Convert to dictionary for YAML serialization."""
|
|
185
|
+
return {self.fact_attribute: self.value}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True, slots=True)
|
|
189
|
+
class EvaluationReport:
|
|
190
|
+
"""Represents the complete evaluation report."""
|
|
191
|
+
|
|
192
|
+
iterations: list[Iteration] = field(default_factory=list)
|
|
193
|
+
|
|
194
|
+
def to_dict(self) -> dict[str, Any]:
|
|
195
|
+
"""Convert to dictionary for YAML serialization."""
|
|
196
|
+
return {"report": {"iterations": [iteration.to_dict() for iteration in self.iterations]}}
|
|
197
|
+
|
|
198
|
+
def to_yaml(self) -> str:
|
|
199
|
+
"""Convert the report to YAML format."""
|
|
200
|
+
|
|
201
|
+
# Create a custom representer for None values
|
|
202
|
+
def represent_none(dumper: yaml.SafeDumper, data: None) -> yaml.ScalarNode:
|
|
203
|
+
return dumper.represent_scalar("tag:yaml.org,2002:str", "None")
|
|
204
|
+
|
|
205
|
+
# Create a custom dumper to avoid global state issues
|
|
206
|
+
class CustomDumper(yaml.SafeDumper):
|
|
207
|
+
pass
|
|
208
|
+
|
|
209
|
+
# Add the custom representer to our custom dumper
|
|
210
|
+
CustomDumper.add_representer(type(None), represent_none)
|
|
211
|
+
|
|
212
|
+
# Also prevent hard-line wrapping by setting a high width
|
|
213
|
+
return yaml.dump(
|
|
214
|
+
self.to_dict(),
|
|
215
|
+
Dumper=CustomDumper,
|
|
216
|
+
default_flow_style=False,
|
|
217
|
+
allow_unicode=True,
|
|
218
|
+
sort_keys=False,
|
|
219
|
+
width=1000000, # Very large width to prevent wrapping
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@dataclass(slots=True)
|
|
224
|
+
class ActionReporter:
|
|
225
|
+
"""Determines the consequences of an rule's action."""
|
|
226
|
+
|
|
227
|
+
action_result: ActionReturn | None
|
|
228
|
+
facts_dict: Mapping[str, Fact]
|
|
229
|
+
consequences: list[RuleConsequence] = field(default_factory=list, init=False)
|
|
230
|
+
|
|
231
|
+
def __post_init__(self):
|
|
232
|
+
self._transform()
|
|
233
|
+
|
|
234
|
+
def _transform(self):
|
|
235
|
+
"""Transform the action result(s) into consequences."""
|
|
236
|
+
if self.action_result is None:
|
|
237
|
+
return
|
|
238
|
+
|
|
239
|
+
if isinstance(self.action_result, tuple):
|
|
240
|
+
# Handle multiple action results
|
|
241
|
+
for item in self.action_result:
|
|
242
|
+
self.consequences.extend(self._fact_to_consequence(item))
|
|
243
|
+
else:
|
|
244
|
+
self.consequences.extend(self._fact_to_consequence(self.action_result))
|
|
245
|
+
|
|
246
|
+
def _fact_to_consequence(self, fact: Fact | partial[Fact]) -> list[RuleConsequence]:
|
|
247
|
+
"""Extract consequences from a single fact or a partial."""
|
|
248
|
+
consequences = []
|
|
249
|
+
|
|
250
|
+
if isinstance(fact, partial):
|
|
251
|
+
# Iterate over a partial's keywords to resolve attributes
|
|
252
|
+
fact_name = fact.func.__name__
|
|
253
|
+
attributes = fact.keywords.items()
|
|
254
|
+
else:
|
|
255
|
+
# For complete fact updates, report all attributes include default values
|
|
256
|
+
fact_name = fact.__class__.__name__
|
|
257
|
+
attributes = [(attr_name, getattr(fact, attr_name)) for attr_name in fact.__annotations__]
|
|
258
|
+
|
|
259
|
+
# Dereference values and append to the consequences list
|
|
260
|
+
for attr_name, value in attributes:
|
|
261
|
+
attr_value = self._dereference(value) if isinstance(fact, partial) else value
|
|
262
|
+
consequences.append(RuleConsequence(fact_name, attr_name, attr_value))
|
|
263
|
+
|
|
264
|
+
return consequences
|
|
265
|
+
|
|
266
|
+
def _dereference(self, value: Any) -> Primitive:
|
|
267
|
+
"""Detects whether the value is reference and resolves it to the actual value."""
|
|
268
|
+
|
|
269
|
+
# FIXME: This needs to be replaced with a better typed solution. This will catch unintended str value cases.
|
|
270
|
+
if isinstance(value, str) and value.startswith("{") and value.endswith("}"):
|
|
271
|
+
# Assume this is a reference, such as "{FactName.attribute}"
|
|
272
|
+
template_content = value[1:-1] # Remove curly braces
|
|
273
|
+
|
|
274
|
+
if "." in template_content:
|
|
275
|
+
fact_name, attr_name = template_content.split(".", 1)
|
|
276
|
+
if fact_name in self.facts_dict:
|
|
277
|
+
fact_instance = self.facts_dict[fact_name]
|
|
278
|
+
return getattr(fact_instance, attr_name)
|
|
279
|
+
|
|
280
|
+
# If the value is not a primitive type, convert it to string
|
|
281
|
+
if not isinstance(value, Primitive):
|
|
282
|
+
value = str(value)
|
|
283
|
+
|
|
284
|
+
return value
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
@dataclass(frozen=True, slots=True)
|
|
288
|
+
class RuleFormatter:
|
|
289
|
+
"""Formats rule data as strings for reporting."""
|
|
290
|
+
|
|
291
|
+
condition: Expression
|
|
292
|
+
fact_map: Mapping[str, Fact]
|
|
293
|
+
result: bool | None = None
|
|
294
|
+
|
|
295
|
+
_expression: str = field(default="", init=False)
|
|
296
|
+
_ai_rationale: str | None = field(default=None, init=False)
|
|
297
|
+
_context: tuple[RuleContext, ...] = field(default_factory=tuple, init=False)
|
|
298
|
+
|
|
299
|
+
@property
|
|
300
|
+
def expression(self) -> str:
|
|
301
|
+
"""Return the formatted rule evaluation expression."""
|
|
302
|
+
return self._expression
|
|
303
|
+
|
|
304
|
+
@property
|
|
305
|
+
def ai_rationale(self) -> str | None:
|
|
306
|
+
"""Return the AI rationale for the condition, if applicable."""
|
|
307
|
+
return self._ai_rationale
|
|
308
|
+
|
|
309
|
+
@property
|
|
310
|
+
def context(self) -> tuple[RuleContext, ...]:
|
|
311
|
+
"""Return the context for long strings or multiline values."""
|
|
312
|
+
return self._context
|
|
313
|
+
|
|
314
|
+
def __post_init__(self):
|
|
315
|
+
expression = self._format_expression(self.condition, result=self.result)
|
|
316
|
+
ai_rationale = self._format_ai_rationale(self.condition)
|
|
317
|
+
context = self._format_context()
|
|
318
|
+
|
|
319
|
+
object.__setattr__(self, "_expression", expression)
|
|
320
|
+
object.__setattr__(self, "_ai_rationale", ai_rationale)
|
|
321
|
+
object.__setattr__(self, "_context", context)
|
|
322
|
+
|
|
323
|
+
def _format_context(self) -> tuple[RuleContext, ...]:
|
|
324
|
+
"""Extract context for long strings (>25 chars or multiline) from evaluation - input data only."""
|
|
325
|
+
context = []
|
|
326
|
+
|
|
327
|
+
# Check condition facts for long strings - only extract input data for conditions
|
|
328
|
+
for fact_ref in self.condition.facts:
|
|
329
|
+
class_name, attr_name = fact_ref.split(".", 1)
|
|
330
|
+
if class_name in self.fact_map:
|
|
331
|
+
fact_instance = self.fact_map[class_name]
|
|
332
|
+
actual_value = getattr(fact_instance, attr_name)
|
|
333
|
+
|
|
334
|
+
if self._should_extract_to_context(actual_value):
|
|
335
|
+
context.append(RuleContext(fact_ref, actual_value))
|
|
336
|
+
|
|
337
|
+
return tuple(context)
|
|
338
|
+
|
|
339
|
+
def _should_extract_to_context(self, value) -> bool:
|
|
340
|
+
"""Determine if a value should be extracted to context."""
|
|
341
|
+
if isinstance(value, str):
|
|
342
|
+
return len(value) > 25 or "\n" in value
|
|
343
|
+
return False
|
|
344
|
+
|
|
345
|
+
def _format_expression(self, condition: Expression, *, result: bool | None) -> str:
|
|
346
|
+
"""Format the evaluation string showing the condition with fact values."""
|
|
347
|
+
|
|
348
|
+
# Format based on condition type
|
|
349
|
+
if isinstance(condition, AICondition):
|
|
350
|
+
expr = self._format_ai_condition(condition)
|
|
351
|
+
elif isinstance(condition, CompoundCondition):
|
|
352
|
+
expr = self._format_compound_condition(condition)
|
|
353
|
+
elif isinstance(condition, Condition):
|
|
354
|
+
expr = self._format_simple_condition(condition)
|
|
355
|
+
else:
|
|
356
|
+
msg = f"Unsupported expression type: {type(condition).__name__}"
|
|
357
|
+
raise ReportGenerationError(msg)
|
|
358
|
+
|
|
359
|
+
# Apply inversion if needed
|
|
360
|
+
if condition.inverted:
|
|
361
|
+
expr = f"not({expr})"
|
|
362
|
+
|
|
363
|
+
return f"{result} = {expr}"
|
|
364
|
+
|
|
365
|
+
def _format_ai_condition(self, condition: AICondition) -> str:
|
|
366
|
+
"""Format an AI condition with its template."""
|
|
367
|
+
# For AI conditions, show the inquiry with fact values substituted
|
|
368
|
+
inquiry = condition.inquiry
|
|
369
|
+
for fact_ref in condition.facts:
|
|
370
|
+
class_name, attr_name = fact_ref.split(".", 1)
|
|
371
|
+
if class_name in self.fact_map:
|
|
372
|
+
fact_instance = self.fact_map[class_name]
|
|
373
|
+
actual_value = getattr(fact_instance, attr_name)
|
|
374
|
+
|
|
375
|
+
# Show the value inline if it is not a long string or multiline
|
|
376
|
+
if not self._should_extract_to_context(actual_value):
|
|
377
|
+
placeholder = f"{{{class_name}.{attr_name}}}"
|
|
378
|
+
inquiry = inquiry.replace(placeholder, f"{{{class_name}.{attr_name}|{actual_value}|}}")
|
|
379
|
+
|
|
380
|
+
return f"{inquiry}"
|
|
381
|
+
|
|
382
|
+
def _format_ai_rationale(self, condition: Expression) -> str | None:
|
|
383
|
+
"""Extract rationale from AI conditions after evaluation."""
|
|
384
|
+
|
|
385
|
+
if isinstance(condition, AICondition):
|
|
386
|
+
return condition.last_rationale()
|
|
387
|
+
elif isinstance(condition, CompoundCondition):
|
|
388
|
+
# Check left and right sides for AI conditions
|
|
389
|
+
left_rationale = self._format_ai_rationale(condition.left)
|
|
390
|
+
right_rationale = self._format_ai_rationale(condition.right)
|
|
391
|
+
|
|
392
|
+
# Combine rationales if both exist
|
|
393
|
+
if left_rationale and right_rationale:
|
|
394
|
+
return f"{left_rationale}; {right_rationale}"
|
|
395
|
+
elif left_rationale:
|
|
396
|
+
return left_rationale
|
|
397
|
+
elif right_rationale:
|
|
398
|
+
return right_rationale
|
|
399
|
+
|
|
400
|
+
return None
|
|
401
|
+
|
|
402
|
+
def _format_simple_condition(self, condition: Condition) -> str:
|
|
403
|
+
"""Format a simple lambda-based condition."""
|
|
404
|
+
|
|
405
|
+
expression = ""
|
|
406
|
+
|
|
407
|
+
if condition.func.__name__ != "<lambda>":
|
|
408
|
+
# Format decoratored function expressions
|
|
409
|
+
expression = f"{condition.func.__name__}()"
|
|
410
|
+
|
|
411
|
+
if condition.evaluated():
|
|
412
|
+
expression += f"|{condition.last_result()}|"
|
|
413
|
+
else:
|
|
414
|
+
expression += "|-|"
|
|
415
|
+
|
|
416
|
+
else:
|
|
417
|
+
# Format lambda expressions
|
|
418
|
+
source = condition.func.__source__
|
|
419
|
+
expression = source.split("lambda:")[1].strip()
|
|
420
|
+
|
|
421
|
+
# Replace fact references with values
|
|
422
|
+
for fact_ref in condition.facts:
|
|
423
|
+
class_name, attr_name = fact_ref.split(".", 1)
|
|
424
|
+
if class_name in self.fact_map:
|
|
425
|
+
fact_instance = self.fact_map[class_name]
|
|
426
|
+
actual_value = getattr(fact_instance, attr_name)
|
|
427
|
+
replacement = f"{class_name}.{attr_name}"
|
|
428
|
+
|
|
429
|
+
# Append the value if it is not a long string or multiline
|
|
430
|
+
if not self._should_extract_to_context(actual_value):
|
|
431
|
+
replacement += f"|{actual_value}|"
|
|
432
|
+
|
|
433
|
+
expression = expression.replace(f"{class_name}.{attr_name}", replacement)
|
|
434
|
+
|
|
435
|
+
# Wrap lambda expressions in parentheses
|
|
436
|
+
expression = f"({expression})"
|
|
437
|
+
|
|
438
|
+
return expression
|
|
439
|
+
|
|
440
|
+
def _format_compound_condition(self, condition: CompoundCondition) -> str:
|
|
441
|
+
"""Format a compound condition with operators."""
|
|
442
|
+
|
|
443
|
+
# Evaluate each side to get the actual boolean results
|
|
444
|
+
left_result = condition.left.last_result()
|
|
445
|
+
right_result = condition.right.last_result()
|
|
446
|
+
|
|
447
|
+
# Format each side with their actual results
|
|
448
|
+
left_str = self._format_expression(condition.left, result=left_result)
|
|
449
|
+
right_str = self._format_expression(condition.right, result=right_result)
|
|
450
|
+
|
|
451
|
+
# Keep just the expression part (after the "= ")
|
|
452
|
+
left_expr, right_expr = [
|
|
453
|
+
value.split(" = ", 1)[1] if " = " in value else value for value in (left_str, right_str)
|
|
454
|
+
]
|
|
455
|
+
|
|
456
|
+
# Format and return the compound expression
|
|
457
|
+
return f"{left_expr} {condition.operator.name.lower()} {right_expr}"
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@dataclass(slots=True)
|
|
461
|
+
class Auditor:
|
|
462
|
+
"""
|
|
463
|
+
Facility to capture runtime iteration and rule state information.
|
|
464
|
+
"""
|
|
465
|
+
|
|
466
|
+
_iteration: Iteration = field(default_factory=Iteration, init=False)
|
|
467
|
+
_evaluation_report: EvaluationReport | None = field(default=None, init=False)
|
|
468
|
+
_rule_stopwatch: StopWatch = field(default_factory=StopWatch, init=False)
|
|
469
|
+
|
|
470
|
+
def evaluation_reset(self) -> None:
|
|
471
|
+
"""Reset the reporter to start a new evaluation report."""
|
|
472
|
+
self._evaluation_report = EvaluationReport()
|
|
473
|
+
|
|
474
|
+
def generate_yaml_report(self) -> str:
|
|
475
|
+
"""Generate YAML report of the tracked evaluation."""
|
|
476
|
+
if not self._evaluation_report:
|
|
477
|
+
msg = "No evaluation report available. Use evaluate(audit=True) to enable tracing."
|
|
478
|
+
raise RuntimeError(msg)
|
|
479
|
+
|
|
480
|
+
return self._evaluation_report.to_yaml()
|
|
481
|
+
|
|
482
|
+
def iteration_start(self) -> None:
|
|
483
|
+
"""Start timing and auditing for a new iteration."""
|
|
484
|
+
self._iteration = Iteration(id=self._iteration.id + 1)
|
|
485
|
+
|
|
486
|
+
def iteration_end(self) -> None:
|
|
487
|
+
"""End iteration timing and create report iteration."""
|
|
488
|
+
self._iteration.stopwatch.stop()
|
|
489
|
+
|
|
490
|
+
if self._iteration.matched_rules and self._evaluation_report is not None:
|
|
491
|
+
self._evaluation_report.iterations.append(self._iteration)
|
|
492
|
+
|
|
493
|
+
def rule_start(self) -> None:
|
|
494
|
+
"""Start timing and auditing for rule execution."""
|
|
495
|
+
self._rule_stopwatch.start()
|
|
496
|
+
|
|
497
|
+
def rule_end(
|
|
498
|
+
self,
|
|
499
|
+
rule: Rule,
|
|
500
|
+
result: ActionReturn | None,
|
|
501
|
+
working_memory: Mapping[str, Fact],
|
|
502
|
+
*,
|
|
503
|
+
condition_result: bool,
|
|
504
|
+
) -> None:
|
|
505
|
+
"""
|
|
506
|
+
Create a RuleMatch from pre-evaluated rule data.
|
|
507
|
+
|
|
508
|
+
Args:
|
|
509
|
+
rule: The rule that was executed
|
|
510
|
+
resolved_facts: Facts that were resolved for the rule
|
|
511
|
+
result: The result of executing the action (or None if no action)
|
|
512
|
+
working_memory: Current facts dictionary for context
|
|
513
|
+
condition_result: The boolean result of the rule condition evaluation
|
|
514
|
+
"""
|
|
515
|
+
|
|
516
|
+
self._rule_stopwatch.stop()
|
|
517
|
+
rule_name = rule.name or "None"
|
|
518
|
+
rule_id = str(rule.id)[:8]
|
|
519
|
+
|
|
520
|
+
# Process action results and track consequences
|
|
521
|
+
action = ActionReporter(result, working_memory)
|
|
522
|
+
warnings = ()
|
|
523
|
+
|
|
524
|
+
# If there was an action, generate warnings and update changed attribute tracking
|
|
525
|
+
if result is not None:
|
|
526
|
+
warnings = self._generate_warnings(result, rule_id)
|
|
527
|
+
self._update_fact_tracking(action.consequences, rule)
|
|
528
|
+
|
|
529
|
+
# Format various report components
|
|
530
|
+
formatter = RuleFormatter(rule.when, working_memory, result=condition_result)
|
|
531
|
+
|
|
532
|
+
# Add tne rule match to the report
|
|
533
|
+
self._iteration.matched_rules.append(
|
|
534
|
+
RuleMatch(
|
|
535
|
+
rule=f"{rule_id}:{rule_name}",
|
|
536
|
+
timestamp=self._rule_stopwatch.timestamp,
|
|
537
|
+
elapsed=self._rule_stopwatch.duration,
|
|
538
|
+
evaluation=formatter.expression,
|
|
539
|
+
consequences=tuple(action.consequences),
|
|
540
|
+
warnings=warnings,
|
|
541
|
+
context=formatter.context,
|
|
542
|
+
rationale=formatter.ai_rationale,
|
|
543
|
+
)
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
def _generate_warnings(self, action_result: ActionReturn | None, rule_id: str) -> tuple[str, ...]:
|
|
547
|
+
"""Check for and report rule warnings"""
|
|
548
|
+
if action_result is None:
|
|
549
|
+
return ()
|
|
550
|
+
|
|
551
|
+
# Handle tuple of results (multiple actions)
|
|
552
|
+
warnings = []
|
|
553
|
+
results = action_result if isinstance(action_result, tuple) else (action_result,)
|
|
554
|
+
|
|
555
|
+
for result in results:
|
|
556
|
+
# Complete Fact replacement: not a partial
|
|
557
|
+
if not isinstance(result, partial):
|
|
558
|
+
fact_name = result.__class__.__name__
|
|
559
|
+
warning_msg = (
|
|
560
|
+
f"Fact Replacement | Rule:{rule_id} consequence replaces "
|
|
561
|
+
f"({fact_name}), potentially altering unintended attributes. "
|
|
562
|
+
f"Consider using a partial update to ensure only intended changes."
|
|
563
|
+
)
|
|
564
|
+
warnings.append(warning_msg)
|
|
565
|
+
else:
|
|
566
|
+
# Partial update: check for attribute overrides
|
|
567
|
+
fact_name = result.func.__name__
|
|
568
|
+
for attr_name, value in result.keywords.items():
|
|
569
|
+
fact_attr = f"{fact_name}.{attr_name}"
|
|
570
|
+
if fact_attr in self._iteration.updated_facts:
|
|
571
|
+
prev_fact_tracker = self._iteration.updated_facts[fact_attr]
|
|
572
|
+
warning_msg = (
|
|
573
|
+
f"Rule Ordering | Rule:{prev_fact_tracker.rule_id} consequence "
|
|
574
|
+
f"({fact_name}.{attr_name}|{prev_fact_tracker.value}|) "
|
|
575
|
+
f"was overridden by Rule:{rule_id} "
|
|
576
|
+
f"({fact_name}.{attr_name}|{value}|) "
|
|
577
|
+
f"within the same iteration"
|
|
578
|
+
)
|
|
579
|
+
warnings.append(warning_msg)
|
|
580
|
+
return tuple(warnings)
|
|
581
|
+
|
|
582
|
+
def _update_fact_tracking(
|
|
583
|
+
self,
|
|
584
|
+
consequences: list[RuleConsequence],
|
|
585
|
+
current_rule: Rule,
|
|
586
|
+
) -> None:
|
|
587
|
+
"""Update the attribute changes tracker with new consequences."""
|
|
588
|
+
rule_id = str(current_rule.id)[:8]
|
|
589
|
+
rule_name = current_rule.name or "None"
|
|
590
|
+
|
|
591
|
+
for consequence in consequences:
|
|
592
|
+
if consequence.attribute_name:
|
|
593
|
+
# This is a partial attribute update
|
|
594
|
+
fact_attr = f"{consequence.fact_name}.{consequence.attribute_name}"
|
|
595
|
+
self._iteration.updated_facts[fact_attr] = FactRecord(rule_id, rule_name, consequence.value)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: vulcan-core
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.2.0
|
|
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,10 +15,11 @@ 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.11.
|
|
18
|
+
Requires-Dist: pydantic (>=2.11.7,<3.0.0)
|
|
19
|
+
Requires-Dist: pyyaml (>=6.0.2,<7.0.0)
|
|
19
20
|
Project-URL: Documentation, https://latchfield.com/vulcan/docs
|
|
20
21
|
Project-URL: Homepage, https://latchfield.com/vulcan
|
|
21
|
-
Project-URL: Repository, https://github.com/latchfield/
|
|
22
|
+
Project-URL: Repository, https://github.com/latchfield/vulcan-core
|
|
22
23
|
Description-Content-Type: text/markdown
|
|
23
24
|
|
|
24
25
|
<!-- SPDX-License-Identifier: Apache-2.0 -->
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
vulcan_core/__init__.py,sha256=pjCnmbMjrsp672WXRQeOV0aSKUEoA_mj0o7q_ouMWs8,1057
|
|
2
|
+
vulcan_core/actions.py,sha256=JeX71MOsNww234vFFJAPTY0kCz-1AhVVZFyrVArKwno,1009
|
|
3
|
+
vulcan_core/ast_utils.py,sha256=U862t03zZOlJzNTYYx4LVtOufPAqxpPB9LjoX5bMGDk,21154
|
|
4
|
+
vulcan_core/conditions.py,sha256=jGr83f3ve6hesltWbkMRHQoeg7wxx_GOyMNYxjomRho,18794
|
|
5
|
+
vulcan_core/engine.py,sha256=W2ki0zR5NuCcxKrM8ii_1uABFAXczIM5sWUNxTJu6dY,11386
|
|
6
|
+
vulcan_core/models.py,sha256=XzeKih2WzKB6Ql_EvAeuVqulrBOmK_Of-0JivATCXaI,8972
|
|
7
|
+
vulcan_core/reporting.py,sha256=p7s5YaGchXdpOHmFLlCpdGppjvWHME5r7iW9DJvPaMQ,22660
|
|
8
|
+
vulcan_core/util.py,sha256=Uq5uWhrfWd8fNv6IeeTFZRGeLBAECPZUx63UjbbSMrA,3420
|
|
9
|
+
vulcan_core-1.2.0.dist-info/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
|
|
10
|
+
vulcan_core-1.2.0.dist-info/METADATA,sha256=aBN7VyUmtKcns3jeupbTzFtJExfmvosVDO6nDcixCB8,4463
|
|
11
|
+
vulcan_core-1.2.0.dist-info/NOTICE,sha256=UN1_Gd_Snmu8T62mxExNUdIF2o7DMdGu8bI8rKqhVnc,244
|
|
12
|
+
vulcan_core-1.2.0.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
|
|
13
|
+
vulcan_core-1.2.0.dist-info/RECORD,,
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
vulcan_core/__init__.py,sha256=pjCnmbMjrsp672WXRQeOV0aSKUEoA_mj0o7q_ouMWs8,1057
|
|
2
|
-
vulcan_core/actions.py,sha256=RO5w5X-drxtDY_mVv0xR2njasWkGPt1AZo9RXsBi8X0,917
|
|
3
|
-
vulcan_core/ast_utils.py,sha256=E0QOr2t49U8kCKtl52KTI8GJCTuBBWYfVLwCzRjZFn0,21340
|
|
4
|
-
vulcan_core/conditions.py,sha256=ZK4plEO2dB7gq0esroEhL29naB_qAsoU4AVSv0rXClk,15670
|
|
5
|
-
vulcan_core/engine.py,sha256=WjayTDEjKaIEVkkSZyDjdbu1Xy1XIvPewI83l6Sjo9g,9672
|
|
6
|
-
vulcan_core/models.py,sha256=7um3u-rAy9gg2otTnZFGlKfHJKfsvGEosU3mGq_0jyg,8964
|
|
7
|
-
vulcan_core/util.py,sha256=Uq5uWhrfWd8fNv6IeeTFZRGeLBAECPZUx63UjbbSMrA,3420
|
|
8
|
-
vulcan_core-1.1.4.dist-info/LICENSE,sha256=psuoW8kuDP96RQsdhzwOqi6fyWv0ct8CR6Jr7He_P_k,10173
|
|
9
|
-
vulcan_core-1.1.4.dist-info/METADATA,sha256=ppV9WmJ7nFE7zBaBbtkCK7VGAaZCM2rD0I35Ck6OtfY,4425
|
|
10
|
-
vulcan_core-1.1.4.dist-info/NOTICE,sha256=UN1_Gd_Snmu8T62mxExNUdIF2o7DMdGu8bI8rKqhVnc,244
|
|
11
|
-
vulcan_core-1.1.4.dist-info/WHEEL,sha256=XbeZDeTWKc1w7CSIyre5aMDU_-PohRwTQceYnisIYYY,88
|
|
12
|
-
vulcan_core-1.1.4.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|