planar 0.9.3__py3-none-any.whl → 0.11.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.
- planar/ai/agent.py +2 -1
- planar/ai/agent_base.py +24 -5
- planar/ai/state.py +17 -0
- planar/app.py +18 -1
- planar/data/connection.py +108 -0
- planar/data/dataset.py +11 -104
- planar/data/utils.py +89 -0
- planar/db/alembic/env.py +25 -1
- planar/files/storage/azure_blob.py +1 -1
- planar/registry_items.py +2 -0
- planar/routers/dataset_router.py +213 -0
- planar/routers/info.py +79 -36
- planar/routers/models.py +1 -0
- planar/routers/workflow.py +2 -0
- planar/scaffold_templates/pyproject.toml.j2 +1 -1
- planar/security/authorization.py +31 -3
- planar/security/default_policies.cedar +25 -0
- planar/testing/fixtures.py +34 -1
- planar/testing/planar_test_client.py +1 -1
- planar/workflows/decorators.py +2 -1
- planar/workflows/wrappers.py +1 -0
- {planar-0.9.3.dist-info → planar-0.11.0.dist-info}/METADATA +9 -1
- {planar-0.9.3.dist-info → planar-0.11.0.dist-info}/RECORD +25 -72
- {planar-0.9.3.dist-info → planar-0.11.0.dist-info}/WHEEL +1 -1
- planar/ai/test_agent_serialization.py +0 -229
- planar/ai/test_agent_tool_step_display.py +0 -78
- planar/data/test_dataset.py +0 -354
- planar/files/storage/test_azure_blob.py +0 -435
- planar/files/storage/test_local_directory.py +0 -162
- planar/files/storage/test_s3.py +0 -299
- planar/files/test_files.py +0 -282
- planar/human/test_human.py +0 -385
- planar/logging/test_formatter.py +0 -327
- planar/modeling/mixins/test_auditable.py +0 -97
- planar/modeling/mixins/test_timestamp.py +0 -134
- planar/modeling/mixins/test_uuid_primary_key.py +0 -52
- planar/routers/test_agents_router.py +0 -174
- planar/routers/test_files_router.py +0 -49
- planar/routers/test_object_config_router.py +0 -367
- planar/routers/test_routes_security.py +0 -168
- planar/routers/test_rule_router.py +0 -470
- planar/routers/test_workflow_router.py +0 -539
- planar/rules/test_data/account_dormancy_management.json +0 -223
- planar/rules/test_data/airline_loyalty_points_calculator.json +0 -262
- planar/rules/test_data/applicant_risk_assessment.json +0 -435
- planar/rules/test_data/booking_fraud_detection.json +0 -407
- planar/rules/test_data/cellular_data_rollover_system.json +0 -258
- planar/rules/test_data/clinical_trial_eligibility_screener.json +0 -437
- planar/rules/test_data/customer_lifetime_value.json +0 -143
- planar/rules/test_data/import_duties_calculator.json +0 -289
- planar/rules/test_data/insurance_prior_authorization.json +0 -443
- planar/rules/test_data/online_check_in_eligibility_system.json +0 -254
- planar/rules/test_data/order_consolidation_system.json +0 -375
- planar/rules/test_data/portfolio_risk_monitor.json +0 -471
- planar/rules/test_data/supply_chain_risk.json +0 -253
- planar/rules/test_data/warehouse_cross_docking.json +0 -237
- planar/rules/test_rules.py +0 -1494
- planar/security/tests/test_auth_middleware.py +0 -162
- planar/security/tests/test_authorization_context.py +0 -78
- planar/security/tests/test_cedar_basics.py +0 -41
- planar/security/tests/test_cedar_policies.py +0 -158
- planar/security/tests/test_jwt_principal_context.py +0 -179
- planar/test_app.py +0 -142
- planar/test_cli.py +0 -394
- planar/test_config.py +0 -515
- planar/test_object_config.py +0 -527
- planar/test_object_registry.py +0 -14
- planar/test_sqlalchemy.py +0 -193
- planar/test_utils.py +0 -105
- planar/testing/test_memory_storage.py +0 -143
- planar/workflows/test_concurrency_detection.py +0 -120
- planar/workflows/test_lock_timeout.py +0 -140
- planar/workflows/test_serialization.py +0 -1203
- planar/workflows/test_suspend_deserialization.py +0 -231
- planar/workflows/test_workflow.py +0 -2005
- {planar-0.9.3.dist-info → planar-0.11.0.dist-info}/entry_points.txt +0 -0
planar/rules/test_rules.py
DELETED
@@ -1,1494 +0,0 @@
|
|
1
|
-
import json
|
2
|
-
from datetime import datetime, timezone
|
3
|
-
from enum import Enum
|
4
|
-
from operator import itemgetter
|
5
|
-
from pathlib import Path
|
6
|
-
from typing import Any, Dict, cast
|
7
|
-
from unittest.mock import patch
|
8
|
-
from uuid import UUID
|
9
|
-
|
10
|
-
import pytest
|
11
|
-
from pydantic import BaseModel, Field, ValidationError
|
12
|
-
from sqlmodel import select
|
13
|
-
from sqlmodel.ext.asyncio.session import AsyncSession
|
14
|
-
|
15
|
-
from planar.object_registry import ObjectRegistry
|
16
|
-
from planar.rules.decorator import RULE_REGISTRY, rule, serialize_for_rule_evaluation
|
17
|
-
from planar.rules.models import JDMGraph, Rule, RuleEngineConfig, create_jdm_graph
|
18
|
-
from planar.rules.rule_configuration import rule_configuration
|
19
|
-
from planar.rules.runner import EvaluateError, EvaluateResponse, evaluate_rule
|
20
|
-
from planar.workflows.decorators import workflow
|
21
|
-
from planar.workflows.execution import lock_and_execute
|
22
|
-
from planar.workflows.models import StepType, WorkflowStatus, WorkflowStep
|
23
|
-
|
24
|
-
|
25
|
-
# Test Enums
|
26
|
-
class CustomerTier(str, Enum):
|
27
|
-
"""Customer tier enumeration."""
|
28
|
-
|
29
|
-
STANDARD = "standard"
|
30
|
-
PREMIUM = "premium"
|
31
|
-
VIP = "vip"
|
32
|
-
|
33
|
-
|
34
|
-
# Test data models
|
35
|
-
class PriceCalculationInput(BaseModel):
|
36
|
-
"""Input for a price calculation rule."""
|
37
|
-
|
38
|
-
product_id: str = Field(description="Product identifier")
|
39
|
-
base_price: float = Field(description="Base price of the product")
|
40
|
-
quantity: int = Field(description="Quantity ordered")
|
41
|
-
customer_tier: CustomerTier = Field(description="Customer tier")
|
42
|
-
|
43
|
-
|
44
|
-
class PriceCalculationOutput(BaseModel):
|
45
|
-
"""Output from a price calculation rule."""
|
46
|
-
|
47
|
-
final_price: float = Field(description="Final calculated price")
|
48
|
-
discount_applied: float = Field(description="Discount percentage applied")
|
49
|
-
discount_reason: str = Field(description="Reason for the discount")
|
50
|
-
|
51
|
-
|
52
|
-
# Default rule implementation for testing
|
53
|
-
DEFAULT_PRICE_CALCULATION = PriceCalculationOutput(
|
54
|
-
final_price=95.0, discount_applied=5.0, discount_reason="Standard 5% discount"
|
55
|
-
)
|
56
|
-
|
57
|
-
|
58
|
-
# Sample JDM graph for overriding the rule
|
59
|
-
PRICE_RULE_JDM_OVERRIDE = {
|
60
|
-
"nodes": [
|
61
|
-
{
|
62
|
-
"id": "input-node",
|
63
|
-
"type": "inputNode",
|
64
|
-
"name": "Input",
|
65
|
-
"content": {
|
66
|
-
"schema": json.dumps(PriceCalculationInput.model_json_schema())
|
67
|
-
},
|
68
|
-
"position": {"x": 100, "y": 100},
|
69
|
-
},
|
70
|
-
{
|
71
|
-
"id": "output-node",
|
72
|
-
"type": "outputNode",
|
73
|
-
"name": "Output",
|
74
|
-
"content": {
|
75
|
-
"schema": json.dumps(PriceCalculationOutput.model_json_schema())
|
76
|
-
},
|
77
|
-
"position": {"x": 700, "y": 100},
|
78
|
-
},
|
79
|
-
{
|
80
|
-
"id": "function-node",
|
81
|
-
"type": "functionNode",
|
82
|
-
"name": "Custom Pricing Logic",
|
83
|
-
"content": {
|
84
|
-
"source": """
|
85
|
-
export const handler = async (input) => {
|
86
|
-
let discount = 0;
|
87
|
-
let reason = "No discount applied";
|
88
|
-
|
89
|
-
if (input.customer_tier === "premium") {
|
90
|
-
discount = 10;
|
91
|
-
reason = "Premium customer discount";
|
92
|
-
} else if (input.customer_tier === "vip") {
|
93
|
-
discount = 15;
|
94
|
-
reason = "VIP customer discount";
|
95
|
-
}
|
96
|
-
|
97
|
-
if (input.quantity > 10) {
|
98
|
-
discount += 5;
|
99
|
-
reason += " + bulk order discount";
|
100
|
-
}
|
101
|
-
|
102
|
-
const finalPrice = input.base_price * input.quantity * (1 - discount/100);
|
103
|
-
|
104
|
-
return {
|
105
|
-
final_price: finalPrice,
|
106
|
-
discount_applied: discount,
|
107
|
-
discount_reason: reason
|
108
|
-
};
|
109
|
-
};
|
110
|
-
"""
|
111
|
-
},
|
112
|
-
"position": {"x": 400, "y": 100},
|
113
|
-
},
|
114
|
-
],
|
115
|
-
"edges": [
|
116
|
-
{
|
117
|
-
"id": "edge1",
|
118
|
-
"sourceId": "input-node",
|
119
|
-
"targetId": "function-node",
|
120
|
-
"type": "edge",
|
121
|
-
},
|
122
|
-
{
|
123
|
-
"id": "edge2",
|
124
|
-
"sourceId": "function-node",
|
125
|
-
"targetId": "output-node",
|
126
|
-
"type": "edge",
|
127
|
-
},
|
128
|
-
],
|
129
|
-
}
|
130
|
-
|
131
|
-
|
132
|
-
@pytest.fixture
|
133
|
-
def price_calculation_rule():
|
134
|
-
"""Returns a rule definition for price calculation testing."""
|
135
|
-
|
136
|
-
@rule(
|
137
|
-
description="Calculate the final price based on product, quantity, and customer tier"
|
138
|
-
)
|
139
|
-
def calculate_price(input: PriceCalculationInput) -> PriceCalculationOutput:
|
140
|
-
# In a real implementation, this would contain business logic
|
141
|
-
# For testing, simply return the default output
|
142
|
-
return DEFAULT_PRICE_CALCULATION
|
143
|
-
|
144
|
-
ObjectRegistry.get_instance().register(calculate_price.__rule__) # type: ignore
|
145
|
-
|
146
|
-
return calculate_price
|
147
|
-
|
148
|
-
|
149
|
-
@pytest.fixture
|
150
|
-
def price_calculation_rule_with_body_variables():
|
151
|
-
"""Returns a rule definition for price calculation testing."""
|
152
|
-
|
153
|
-
@rule(
|
154
|
-
description="Calculate the final price based on product, quantity, and customer tier"
|
155
|
-
)
|
156
|
-
def calculate_price(input: PriceCalculationInput) -> PriceCalculationOutput:
|
157
|
-
some_variable = 10
|
158
|
-
return PriceCalculationOutput(
|
159
|
-
final_price=input.base_price * some_variable,
|
160
|
-
discount_applied=0,
|
161
|
-
discount_reason="No discount applied",
|
162
|
-
)
|
163
|
-
|
164
|
-
return calculate_price
|
165
|
-
|
166
|
-
|
167
|
-
@pytest.fixture
|
168
|
-
def price_calculation_input():
|
169
|
-
"""Returns sample price calculation input for testing."""
|
170
|
-
return {
|
171
|
-
"product_id": "PROD-123",
|
172
|
-
"base_price": 100.0,
|
173
|
-
"quantity": 1,
|
174
|
-
"customer_tier": "standard",
|
175
|
-
}
|
176
|
-
|
177
|
-
|
178
|
-
async def test_rule_initialization():
|
179
|
-
"""Test that a rule function is properly initialized with the @rule decorator."""
|
180
|
-
|
181
|
-
@rule(description="Test rule initialization")
|
182
|
-
def test_rule(input: PriceCalculationInput) -> PriceCalculationOutput:
|
183
|
-
return DEFAULT_PRICE_CALCULATION
|
184
|
-
|
185
|
-
# The rule should be registered in the RULE_REGISTRY
|
186
|
-
assert "test_rule" in RULE_REGISTRY
|
187
|
-
registered_rule = RULE_REGISTRY["test_rule"]
|
188
|
-
|
189
|
-
# Verify initialization
|
190
|
-
assert registered_rule.name == "test_rule"
|
191
|
-
assert registered_rule.description == "Test rule initialization"
|
192
|
-
assert registered_rule.input == PriceCalculationInput
|
193
|
-
assert registered_rule.output == PriceCalculationOutput
|
194
|
-
|
195
|
-
|
196
|
-
async def test_rule_type_validation():
|
197
|
-
"""Test that the rule decorator properly validates input and output types."""
|
198
|
-
|
199
|
-
# Should raise ValueError when input type is not a Pydantic model
|
200
|
-
with pytest.raises(ValueError):
|
201
|
-
# Using Any to avoid the actual type check in pytest itself
|
202
|
-
# The validation function in the decorator will still catch this
|
203
|
-
@rule(description="Invalid input type")
|
204
|
-
def invalid_input_rule(input: Any) -> PriceCalculationOutput:
|
205
|
-
return DEFAULT_PRICE_CALCULATION
|
206
|
-
|
207
|
-
# Should raise ValueError when output type is not a Pydantic model
|
208
|
-
with pytest.raises(ValueError):
|
209
|
-
# Using Any to avoid the actual type check in pytest itself
|
210
|
-
@rule(description="Invalid output type")
|
211
|
-
def invalid_output_rule(input: PriceCalculationInput) -> Any:
|
212
|
-
return "Invalid"
|
213
|
-
|
214
|
-
# Should raise ValueError when missing type annotations
|
215
|
-
with pytest.raises(ValueError):
|
216
|
-
# Missing type annotation for input
|
217
|
-
@rule(description="Missing annotations")
|
218
|
-
def missing_annotations_rule(input):
|
219
|
-
return DEFAULT_PRICE_CALCULATION
|
220
|
-
|
221
|
-
# Should raise ValueError when missing return type
|
222
|
-
with pytest.raises(ValueError):
|
223
|
-
# The decorator function should catch this
|
224
|
-
@rule(description="Missing return type")
|
225
|
-
def missing_return_type(input: PriceCalculationInput):
|
226
|
-
return DEFAULT_PRICE_CALCULATION
|
227
|
-
|
228
|
-
|
229
|
-
async def test_rule_in_workflow(session: AsyncSession, price_calculation_rule):
|
230
|
-
"""Test that a rule can be used in a workflow."""
|
231
|
-
|
232
|
-
@workflow()
|
233
|
-
async def pricing_workflow(input_data: Dict):
|
234
|
-
input_model = PriceCalculationInput(**input_data)
|
235
|
-
result = await price_calculation_rule(input_model)
|
236
|
-
return result
|
237
|
-
|
238
|
-
# Start the workflow and run it
|
239
|
-
input_data = {
|
240
|
-
"product_id": "PROD-123",
|
241
|
-
"base_price": 100.0,
|
242
|
-
"quantity": 1,
|
243
|
-
"customer_tier": "standard",
|
244
|
-
}
|
245
|
-
|
246
|
-
wf = await pricing_workflow.start(input_data)
|
247
|
-
result = await lock_and_execute(wf)
|
248
|
-
|
249
|
-
# Verify workflow completed successfully
|
250
|
-
assert wf.status == WorkflowStatus.SUCCEEDED
|
251
|
-
assert wf.result == DEFAULT_PRICE_CALCULATION.model_dump()
|
252
|
-
|
253
|
-
assert isinstance(result, PriceCalculationOutput)
|
254
|
-
assert result.final_price == DEFAULT_PRICE_CALCULATION.final_price
|
255
|
-
assert result.discount_applied == DEFAULT_PRICE_CALCULATION.discount_applied
|
256
|
-
assert result.discount_reason == DEFAULT_PRICE_CALCULATION.discount_reason
|
257
|
-
|
258
|
-
# Verify steps were recorded correctly
|
259
|
-
steps = (
|
260
|
-
await session.exec(
|
261
|
-
select(WorkflowStep).where(WorkflowStep.workflow_id == wf.id)
|
262
|
-
)
|
263
|
-
).all()
|
264
|
-
assert len(steps) >= 1
|
265
|
-
|
266
|
-
# Find the rule step
|
267
|
-
rule_step = next((step for step in steps if step.step_type == StepType.RULE), None)
|
268
|
-
assert rule_step is not None
|
269
|
-
assert price_calculation_rule.__name__ in rule_step.function_name
|
270
|
-
|
271
|
-
|
272
|
-
async def test_rule_in_workflow_with_body_variables(
|
273
|
-
session: AsyncSession, price_calculation_rule_with_body_variables
|
274
|
-
):
|
275
|
-
"""Test that a rule can be used in a workflow."""
|
276
|
-
|
277
|
-
@workflow()
|
278
|
-
async def pricing_workflow(input_data: Dict):
|
279
|
-
input_model = PriceCalculationInput(**input_data)
|
280
|
-
result = await price_calculation_rule_with_body_variables(input_model)
|
281
|
-
return result
|
282
|
-
|
283
|
-
# Start the workflow and run it
|
284
|
-
input_data = {
|
285
|
-
"product_id": "PROD-123",
|
286
|
-
"base_price": 10.0,
|
287
|
-
"quantity": 1,
|
288
|
-
"customer_tier": "standard",
|
289
|
-
}
|
290
|
-
|
291
|
-
wf = await pricing_workflow.start(input_data)
|
292
|
-
result = await lock_and_execute(wf)
|
293
|
-
|
294
|
-
# Verify workflow completed successfully
|
295
|
-
assert wf.status == WorkflowStatus.SUCCEEDED
|
296
|
-
assert (
|
297
|
-
wf.result
|
298
|
-
== PriceCalculationOutput(
|
299
|
-
final_price=100.0, discount_applied=0, discount_reason="No discount applied"
|
300
|
-
).model_dump()
|
301
|
-
)
|
302
|
-
|
303
|
-
assert isinstance(result, PriceCalculationOutput)
|
304
|
-
assert result.final_price == 100.0
|
305
|
-
assert result.discount_applied == 0
|
306
|
-
assert result.discount_reason == "No discount applied"
|
307
|
-
|
308
|
-
|
309
|
-
async def test_rule_override(session: AsyncSession, price_calculation_rule):
|
310
|
-
"""Test that a rule can be overridden with a JDM graph."""
|
311
|
-
|
312
|
-
# Create and save an override
|
313
|
-
override = RuleEngineConfig(jdm=JDMGraph.model_validate(PRICE_RULE_JDM_OVERRIDE))
|
314
|
-
|
315
|
-
cfg = await rule_configuration.write_config(
|
316
|
-
price_calculation_rule.__name__, override
|
317
|
-
)
|
318
|
-
await rule_configuration.promote_config(cfg.id)
|
319
|
-
|
320
|
-
@workflow()
|
321
|
-
async def pricing_workflow(input_data: Dict):
|
322
|
-
input_model = PriceCalculationInput(**input_data)
|
323
|
-
result = await price_calculation_rule(input_model)
|
324
|
-
return result
|
325
|
-
|
326
|
-
# Start the workflow with premium customer input
|
327
|
-
premium_input = {
|
328
|
-
"product_id": "PROD-456",
|
329
|
-
"base_price": 100.0,
|
330
|
-
"quantity": 5,
|
331
|
-
"customer_tier": "premium",
|
332
|
-
}
|
333
|
-
|
334
|
-
wf = await pricing_workflow.start(premium_input)
|
335
|
-
_ = await lock_and_execute(wf)
|
336
|
-
|
337
|
-
# Verify the workflow used the override logic
|
338
|
-
assert wf.status == WorkflowStatus.SUCCEEDED
|
339
|
-
assert wf.result is not None
|
340
|
-
assert wf.result != DEFAULT_PRICE_CALCULATION.model_dump()
|
341
|
-
assert wf.result["discount_applied"] == 10.0
|
342
|
-
assert "Premium customer discount" in wf.result["discount_reason"]
|
343
|
-
|
344
|
-
# Now test with VIP customer and bulk order
|
345
|
-
vip_bulk_input = {
|
346
|
-
"product_id": "PROD-789",
|
347
|
-
"base_price": 100.0,
|
348
|
-
"quantity": 15,
|
349
|
-
"customer_tier": "vip",
|
350
|
-
}
|
351
|
-
|
352
|
-
wf2 = await pricing_workflow.start(vip_bulk_input)
|
353
|
-
_ = await lock_and_execute(wf2)
|
354
|
-
|
355
|
-
# Verify the workflow used the override logic with both discounts
|
356
|
-
assert wf2.status == WorkflowStatus.SUCCEEDED
|
357
|
-
assert wf2.result is not None
|
358
|
-
assert wf2.result["discount_applied"] == 20.0 # 15% VIP + 5% bulk
|
359
|
-
assert "VIP customer discount" in wf2.result["discount_reason"]
|
360
|
-
assert "bulk order discount" in wf2.result["discount_reason"]
|
361
|
-
|
362
|
-
|
363
|
-
async def test_evaluate_rule_function():
|
364
|
-
"""Test the evaluate_rule function directly."""
|
365
|
-
|
366
|
-
# Create test input data
|
367
|
-
input_data = {
|
368
|
-
"product_id": "PROD-123",
|
369
|
-
"base_price": 100.0,
|
370
|
-
"quantity": 5,
|
371
|
-
"customer_tier": "premium",
|
372
|
-
}
|
373
|
-
|
374
|
-
# Test error handling
|
375
|
-
with patch("planar.rules.runner.ZenEngine") as MockZenEngine:
|
376
|
-
mock_decision = MockZenEngine.return_value.create_decision.return_value
|
377
|
-
error_json = json.dumps(
|
378
|
-
{
|
379
|
-
"type": "RuleEvaluationError",
|
380
|
-
"source": json.dumps({"error": "Invalid rule logic"}),
|
381
|
-
"nodeId": "decision-table-node",
|
382
|
-
}
|
383
|
-
)
|
384
|
-
mock_decision.evaluate.side_effect = RuntimeError(error_json)
|
385
|
-
|
386
|
-
result = evaluate_rule(
|
387
|
-
JDMGraph.model_validate(PRICE_RULE_JDM_OVERRIDE), input_data
|
388
|
-
)
|
389
|
-
|
390
|
-
assert isinstance(result, EvaluateError)
|
391
|
-
assert result.success is False
|
392
|
-
assert result.title == "RuleEvaluationError"
|
393
|
-
assert result.message == {"error": "Invalid rule logic"}
|
394
|
-
assert result.data["nodeId"] == "decision-table-node"
|
395
|
-
|
396
|
-
|
397
|
-
async def test_rule_override_validation(session: AsyncSession, price_calculation_rule):
|
398
|
-
"""Test validation when creating a rule override."""
|
399
|
-
|
400
|
-
ObjectRegistry.get_instance().register(price_calculation_rule.__rule__)
|
401
|
-
|
402
|
-
# Test with valid JDMGraph
|
403
|
-
valid_jdm = create_jdm_graph(price_calculation_rule.__rule__)
|
404
|
-
valid_override = RuleEngineConfig(jdm=valid_jdm)
|
405
|
-
assert valid_override is not None
|
406
|
-
assert isinstance(valid_override.jdm, JDMGraph)
|
407
|
-
await rule_configuration.write_config(
|
408
|
-
price_calculation_rule.__name__, valid_override
|
409
|
-
)
|
410
|
-
|
411
|
-
# Query back and verify
|
412
|
-
configs = await rule_configuration._read_configs(price_calculation_rule.__name__)
|
413
|
-
assert len(configs) == 1
|
414
|
-
assert configs[0].object_name == price_calculation_rule.__name__
|
415
|
-
assert JDMGraph.model_validate(configs[0].data.jdm) == valid_jdm
|
416
|
-
|
417
|
-
# Test with invalid JDMGraph (missing required fields)
|
418
|
-
with pytest.raises(ValidationError):
|
419
|
-
# Test with incomplete dictionary
|
420
|
-
invalid_dict = {"invalid": "structure"}
|
421
|
-
JDMGraph.model_validate(invalid_dict)
|
422
|
-
|
423
|
-
# Test with invalid JDMGraph type
|
424
|
-
with pytest.raises(ValidationError):
|
425
|
-
# Test with completely wrong type
|
426
|
-
RuleEngineConfig(jdm="invalid_string") # type: ignore
|
427
|
-
|
428
|
-
|
429
|
-
def test_serialize_for_rule_evaluation_dict():
|
430
|
-
"""Test serialization of dictionaries with nested datetime and UUID objects."""
|
431
|
-
|
432
|
-
test_uuid = UUID("12345678-1234-5678-1234-567812345678")
|
433
|
-
naive_dt = datetime(2023, 12, 25, 14, 30, 45)
|
434
|
-
aware_dt = datetime(2023, 12, 25, 14, 30, 45, tzinfo=timezone.utc)
|
435
|
-
|
436
|
-
test_dict = {
|
437
|
-
"id": test_uuid,
|
438
|
-
"created_at": naive_dt,
|
439
|
-
"updated_at": aware_dt,
|
440
|
-
"name": "test_item",
|
441
|
-
"count": 42,
|
442
|
-
"nested": {"another_id": test_uuid, "another_date": naive_dt},
|
443
|
-
}
|
444
|
-
|
445
|
-
serialized = serialize_for_rule_evaluation(test_dict)
|
446
|
-
|
447
|
-
assert serialized["id"] == "12345678-1234-5678-1234-567812345678"
|
448
|
-
assert serialized["created_at"] == "2023-12-25T14:30:45Z"
|
449
|
-
assert serialized["updated_at"] == "2023-12-25T14:30:45+00:00"
|
450
|
-
assert serialized["name"] == "test_item"
|
451
|
-
assert serialized["count"] == 42
|
452
|
-
assert serialized["nested"]["another_id"] == "12345678-1234-5678-1234-567812345678"
|
453
|
-
assert serialized["nested"]["another_date"] == "2023-12-25T14:30:45Z"
|
454
|
-
|
455
|
-
|
456
|
-
def test_serialize_for_rule_evaluation():
|
457
|
-
"""Test serialization of complex nested structures."""
|
458
|
-
|
459
|
-
test_uuid1 = UUID("12345678-1234-5678-1234-567812345678")
|
460
|
-
test_uuid2 = UUID("87654321-4321-8765-4321-876543218765")
|
461
|
-
naive_dt = datetime(2023, 12, 25, 14, 30, 45, 123456)
|
462
|
-
aware_dt = datetime(2023, 12, 25, 14, 30, 45, 123456, timezone.utc)
|
463
|
-
|
464
|
-
complex_data = {
|
465
|
-
"metadata": {
|
466
|
-
"id": test_uuid1,
|
467
|
-
"created_at": naive_dt,
|
468
|
-
"updated_at": aware_dt,
|
469
|
-
"tags": ["tag1", "tag2", test_uuid2],
|
470
|
-
},
|
471
|
-
"items": [
|
472
|
-
{
|
473
|
-
"item_id": test_uuid1,
|
474
|
-
"timestamp": naive_dt,
|
475
|
-
"values": (1, 2, 3, aware_dt),
|
476
|
-
},
|
477
|
-
{
|
478
|
-
"item_id": test_uuid2,
|
479
|
-
"timestamp": aware_dt,
|
480
|
-
"nested_list": [{"deep_uuid": test_uuid1, "deep_date": naive_dt}],
|
481
|
-
},
|
482
|
-
],
|
483
|
-
"enum_values": [CustomerTier.STANDARD],
|
484
|
-
"simple_values": [1, "test", True, None],
|
485
|
-
}
|
486
|
-
|
487
|
-
serialized = serialize_for_rule_evaluation(complex_data)
|
488
|
-
|
489
|
-
# Verify metadata
|
490
|
-
assert serialized["metadata"]["id"] == "12345678-1234-5678-1234-567812345678"
|
491
|
-
assert serialized["metadata"]["created_at"] == "2023-12-25T14:30:45.123456Z"
|
492
|
-
assert serialized["metadata"]["updated_at"] == "2023-12-25T14:30:45.123456+00:00"
|
493
|
-
assert serialized["metadata"]["tags"][2] == "87654321-4321-8765-4321-876543218765"
|
494
|
-
|
495
|
-
# Verify items
|
496
|
-
assert serialized["items"][0]["item_id"] == "12345678-1234-5678-1234-567812345678"
|
497
|
-
assert serialized["items"][0]["timestamp"] == "2023-12-25T14:30:45.123456Z"
|
498
|
-
assert serialized["items"][0]["values"][3] == "2023-12-25T14:30:45.123456+00:00"
|
499
|
-
|
500
|
-
assert serialized["items"][1]["item_id"] == "87654321-4321-8765-4321-876543218765"
|
501
|
-
assert serialized["items"][1]["timestamp"] == "2023-12-25T14:30:45.123456+00:00"
|
502
|
-
assert (
|
503
|
-
serialized["items"][1]["nested_list"][0]["deep_uuid"]
|
504
|
-
== "12345678-1234-5678-1234-567812345678"
|
505
|
-
)
|
506
|
-
assert (
|
507
|
-
serialized["items"][1]["nested_list"][0]["deep_date"]
|
508
|
-
== "2023-12-25T14:30:45.123456Z"
|
509
|
-
)
|
510
|
-
|
511
|
-
# Verify simple values remain unchanged
|
512
|
-
assert serialized["simple_values"] == [1, "test", True, None]
|
513
|
-
|
514
|
-
|
515
|
-
class DateTimeTestModel(BaseModel):
|
516
|
-
"""Test model with datetime fields for integration testing."""
|
517
|
-
|
518
|
-
id: UUID = Field(description="Unique identifier")
|
519
|
-
created_at: datetime = Field(description="Creation timestamp")
|
520
|
-
updated_at: datetime | None = Field(default=None, description="Update timestamp")
|
521
|
-
name: str = Field(description="Name of the item")
|
522
|
-
|
523
|
-
|
524
|
-
def test_serialize_pydantic_model_with_datetime():
|
525
|
-
"""Test serialization of Pydantic models containing datetime fields."""
|
526
|
-
|
527
|
-
test_uuid = UUID("12345678-1234-5678-1234-567812345678")
|
528
|
-
naive_dt = datetime(2023, 12, 25, 14, 30, 45, 123456)
|
529
|
-
aware_dt = datetime(2023, 12, 25, 14, 30, 45, 123456, timezone.utc)
|
530
|
-
|
531
|
-
model = DateTimeTestModel(
|
532
|
-
id=test_uuid, created_at=naive_dt, updated_at=aware_dt, name="test_model"
|
533
|
-
)
|
534
|
-
|
535
|
-
# Serialize the model's dict representation
|
536
|
-
model_dict = model.model_dump()
|
537
|
-
serialized = serialize_for_rule_evaluation(model_dict)
|
538
|
-
|
539
|
-
assert serialized["id"] == "12345678-1234-5678-1234-567812345678"
|
540
|
-
assert serialized["created_at"] == "2023-12-25T14:30:45.123456Z"
|
541
|
-
assert serialized["updated_at"] == "2023-12-25T14:30:45.123456+00:00"
|
542
|
-
assert serialized["name"] == "test_model"
|
543
|
-
|
544
|
-
|
545
|
-
async def test_rule_with_complex_types_serialization(session: AsyncSession):
|
546
|
-
"""Integration test: Test that complex types serialization works in rule evaluation."""
|
547
|
-
|
548
|
-
class ComplexTypesInput(BaseModel):
|
549
|
-
event_id: UUID
|
550
|
-
event_time: datetime
|
551
|
-
event_name: str
|
552
|
-
enum_value: CustomerTier
|
553
|
-
|
554
|
-
class ComplexTypesOutput(BaseModel):
|
555
|
-
processed_id: UUID
|
556
|
-
processed_time: datetime
|
557
|
-
enum_value: CustomerTier
|
558
|
-
message: str
|
559
|
-
|
560
|
-
@rule(description="Process datetime input")
|
561
|
-
def process_datetime_rule(input: ComplexTypesInput) -> ComplexTypesOutput:
|
562
|
-
# Should actually be using the rule override below.
|
563
|
-
return ComplexTypesOutput(
|
564
|
-
processed_id=UUID("12345678-1234-5678-1234-567812345678"),
|
565
|
-
processed_time=datetime.now(timezone.utc),
|
566
|
-
enum_value=CustomerTier.STANDARD,
|
567
|
-
message="Should not be using this default rule",
|
568
|
-
)
|
569
|
-
|
570
|
-
ObjectRegistry.get_instance().register(process_datetime_rule.__rule__) # type: ignore
|
571
|
-
|
572
|
-
# Create a JDM override that uses the datetime fields
|
573
|
-
datetime_jdm_override = {
|
574
|
-
"nodes": [
|
575
|
-
{
|
576
|
-
"id": "input-node",
|
577
|
-
"type": "inputNode",
|
578
|
-
"name": "Input",
|
579
|
-
"content": {
|
580
|
-
"schema": json.dumps(ComplexTypesInput.model_json_schema())
|
581
|
-
},
|
582
|
-
"position": {"x": 100, "y": 100},
|
583
|
-
},
|
584
|
-
{
|
585
|
-
"id": "output-node",
|
586
|
-
"type": "outputNode",
|
587
|
-
"name": "Output",
|
588
|
-
"content": {
|
589
|
-
"schema": json.dumps(ComplexTypesOutput.model_json_schema())
|
590
|
-
},
|
591
|
-
"position": {"x": 700, "y": 100},
|
592
|
-
},
|
593
|
-
{
|
594
|
-
"id": "function-node",
|
595
|
-
"type": "functionNode",
|
596
|
-
"name": "DateTime Processing",
|
597
|
-
"content": {
|
598
|
-
"source": """
|
599
|
-
export const handler = async (input) => {
|
600
|
-
return {
|
601
|
-
processed_id: input.event_id,
|
602
|
-
processed_time: input.event_time,
|
603
|
-
enum_value: input.enum_value,
|
604
|
-
message: `Override processed ${input.event_name}`
|
605
|
-
};
|
606
|
-
};
|
607
|
-
"""
|
608
|
-
},
|
609
|
-
"position": {"x": 400, "y": 100},
|
610
|
-
},
|
611
|
-
],
|
612
|
-
"edges": [
|
613
|
-
{
|
614
|
-
"id": "edge1",
|
615
|
-
"sourceId": "input-node",
|
616
|
-
"targetId": "function-node",
|
617
|
-
"type": "edge",
|
618
|
-
},
|
619
|
-
{
|
620
|
-
"id": "edge2",
|
621
|
-
"sourceId": "function-node",
|
622
|
-
"targetId": "output-node",
|
623
|
-
"type": "edge",
|
624
|
-
},
|
625
|
-
],
|
626
|
-
}
|
627
|
-
|
628
|
-
# Create and save an override
|
629
|
-
override = RuleEngineConfig(jdm=JDMGraph.model_validate(datetime_jdm_override))
|
630
|
-
cfg = await rule_configuration.write_config(
|
631
|
-
process_datetime_rule.__name__, override
|
632
|
-
)
|
633
|
-
await rule_configuration.promote_config(cfg.id)
|
634
|
-
|
635
|
-
@workflow()
|
636
|
-
async def datetime_workflow(input: ComplexTypesInput):
|
637
|
-
result = await process_datetime_rule(input)
|
638
|
-
return result
|
639
|
-
|
640
|
-
# Test with naive datetime
|
641
|
-
test_uuid = UUID("12345678-1234-5678-1234-567812345678")
|
642
|
-
naive_dt = datetime(2023, 12, 25, 14, 30, 45, 123456)
|
643
|
-
|
644
|
-
input = ComplexTypesInput(
|
645
|
-
event_id=test_uuid,
|
646
|
-
event_time=naive_dt,
|
647
|
-
event_name="test_event",
|
648
|
-
enum_value=CustomerTier.STANDARD,
|
649
|
-
)
|
650
|
-
|
651
|
-
wf = await datetime_workflow.start(input)
|
652
|
-
await lock_and_execute(wf)
|
653
|
-
|
654
|
-
# Verify the workflow completed successfully
|
655
|
-
assert wf.status == WorkflowStatus.SUCCEEDED
|
656
|
-
assert wf.result is not None
|
657
|
-
assert ComplexTypesOutput.model_validate(wf.result) == ComplexTypesOutput(
|
658
|
-
processed_id=test_uuid,
|
659
|
-
processed_time=naive_dt.replace(tzinfo=timezone.utc),
|
660
|
-
enum_value=CustomerTier.STANDARD,
|
661
|
-
message="Override processed test_event",
|
662
|
-
)
|
663
|
-
|
664
|
-
|
665
|
-
async def test_create_jdm_graph():
|
666
|
-
"""Test JDM graph generation from rule schemas."""
|
667
|
-
rule = Rule(
|
668
|
-
name="test_price_rule",
|
669
|
-
description="Test price calculation rule",
|
670
|
-
input=PriceCalculationInput,
|
671
|
-
output=PriceCalculationOutput,
|
672
|
-
)
|
673
|
-
|
674
|
-
# Generate the JDM graph
|
675
|
-
jdm_graph = create_jdm_graph(rule)
|
676
|
-
|
677
|
-
# Verify the structure
|
678
|
-
assert len(jdm_graph.nodes) == 3 # input, decision table, output
|
679
|
-
assert len(jdm_graph.edges) == 2 # input->table, table->output
|
680
|
-
|
681
|
-
# Verify node types
|
682
|
-
node_types = {node.type for node in jdm_graph.nodes}
|
683
|
-
assert node_types == {"inputNode", "decisionTableNode", "outputNode"}
|
684
|
-
|
685
|
-
# Find the decision table node
|
686
|
-
decision_table = next(
|
687
|
-
node for node in jdm_graph.nodes if node.type == "decisionTableNode"
|
688
|
-
)
|
689
|
-
|
690
|
-
# Verify output columns match the output schema
|
691
|
-
output_columns = decision_table.content.outputs
|
692
|
-
assert len(output_columns) == 3 # final_price, discount_applied, discount_reason
|
693
|
-
|
694
|
-
output_fields = {col.field for col in output_columns}
|
695
|
-
assert output_fields == {"final_price", "discount_applied", "discount_reason"}
|
696
|
-
|
697
|
-
# Verify rule values have correct default types
|
698
|
-
rule_values = decision_table.content.rules[0]
|
699
|
-
|
700
|
-
# Find column IDs for each field
|
701
|
-
final_price_col = next(col for col in output_columns if col.field == "final_price")
|
702
|
-
discount_applied_col = next(
|
703
|
-
col for col in output_columns if col.field == "discount_applied"
|
704
|
-
)
|
705
|
-
discount_reason_col = next(
|
706
|
-
col for col in output_columns if col.field == "discount_reason"
|
707
|
-
)
|
708
|
-
|
709
|
-
assert rule_values[final_price_col.id] == "0" # number default
|
710
|
-
assert rule_values[discount_applied_col.id] == "0" # number default
|
711
|
-
assert rule_values[discount_reason_col.id] == '"default value"' # string default
|
712
|
-
|
713
|
-
# Verify input and output nodes have proper schemas
|
714
|
-
input_node = next(node for node in jdm_graph.nodes if node.type == "inputNode")
|
715
|
-
output_node = next(node for node in jdm_graph.nodes if node.type == "outputNode")
|
716
|
-
|
717
|
-
input_schema = json.loads(input_node.content.schema_)
|
718
|
-
output_schema = json.loads(output_node.content.schema_)
|
719
|
-
|
720
|
-
assert input_schema == PriceCalculationInput.model_json_schema()
|
721
|
-
assert output_schema == PriceCalculationOutput.model_json_schema()
|
722
|
-
|
723
|
-
|
724
|
-
async def test_jdm_graph_evaluation():
|
725
|
-
"""Test evaluating a JDM graph with a simple rule."""
|
726
|
-
|
727
|
-
# Create a rule and generate its JDM graph
|
728
|
-
@rule(description="Test JDM evaluation")
|
729
|
-
def simple_rule(input: PriceCalculationInput) -> PriceCalculationOutput:
|
730
|
-
return DEFAULT_PRICE_CALCULATION
|
731
|
-
|
732
|
-
jdm_graph = create_jdm_graph(RULE_REGISTRY[simple_rule.__name__])
|
733
|
-
|
734
|
-
# Test input data
|
735
|
-
test_input = {
|
736
|
-
"product_id": "PROD-EVAL",
|
737
|
-
"base_price": 200.0,
|
738
|
-
"quantity": 2,
|
739
|
-
"customer_tier": "vip",
|
740
|
-
}
|
741
|
-
|
742
|
-
# Evaluate the rule
|
743
|
-
result = evaluate_rule(jdm_graph, test_input)
|
744
|
-
|
745
|
-
# Verify the result
|
746
|
-
assert isinstance(result, EvaluateResponse)
|
747
|
-
assert result.success is True
|
748
|
-
assert result.result["final_price"] == 0.0
|
749
|
-
assert result.result["discount_applied"] == 0.0
|
750
|
-
assert "default value" in result.result["discount_reason"]
|
751
|
-
|
752
|
-
|
753
|
-
def test_evalute_rule_with_airline_loyalty_points_calculator_rule():
|
754
|
-
airline_loyalty_points_calculator_path = (
|
755
|
-
Path(__file__).parent / "test_data" / "airline_loyalty_points_calculator.json"
|
756
|
-
)
|
757
|
-
with open(airline_loyalty_points_calculator_path, "r", encoding="utf-8") as f:
|
758
|
-
jdm_dict = json.load(f)
|
759
|
-
|
760
|
-
input_data = {
|
761
|
-
"booking": {
|
762
|
-
"fareClass": "Business",
|
763
|
-
"routeType": "International",
|
764
|
-
"distance": 3500,
|
765
|
-
"isSeasonalPromotion": True,
|
766
|
-
},
|
767
|
-
"member": {
|
768
|
-
"status": "Gold",
|
769
|
-
"id": "MEM12345",
|
770
|
-
"name": "John Smith",
|
771
|
-
"enrollmentDate": "2020-05-15",
|
772
|
-
},
|
773
|
-
}
|
774
|
-
|
775
|
-
result = evaluate_rule(JDMGraph.model_validate(jdm_dict), input_data)
|
776
|
-
|
777
|
-
assert result.success
|
778
|
-
assert cast(EvaluateResponse, result).result == {
|
779
|
-
"calculatedPoints": 9000,
|
780
|
-
"seasonalPromotion": 1.5,
|
781
|
-
"totalPoints": 9000,
|
782
|
-
}
|
783
|
-
|
784
|
-
|
785
|
-
def test_evalute_rule_with_account_dormancy_management_rule():
|
786
|
-
account_dormancy_management_path = (
|
787
|
-
Path(__file__).parent / "test_data" / "account_dormancy_management.json"
|
788
|
-
)
|
789
|
-
with open(account_dormancy_management_path, "r", encoding="utf-8") as f:
|
790
|
-
jdm_dict = json.load(f)
|
791
|
-
|
792
|
-
input_data = {
|
793
|
-
"accountId": "ACC98765432",
|
794
|
-
"accountType": "savings",
|
795
|
-
"customerTier": "premium",
|
796
|
-
"lastActivityDate": "2024-11-15",
|
797
|
-
"dormancyThreshold": 180,
|
798
|
-
"accountBalance": 25750.45,
|
799
|
-
"currency": "USD",
|
800
|
-
"region": "NORTH_AMERICA",
|
801
|
-
"contactPreference": "email",
|
802
|
-
"customerEmail": "customer@example.com",
|
803
|
-
"customerPhone": "+15551234567",
|
804
|
-
"regulatoryJurisdiction": "US-NY",
|
805
|
-
}
|
806
|
-
|
807
|
-
result = evaluate_rule(JDMGraph.model_validate(jdm_dict), input_data)
|
808
|
-
|
809
|
-
assert result.success
|
810
|
-
assert cast(EvaluateResponse, result).result == {
|
811
|
-
"actionPriority": "high",
|
812
|
-
"recommendedAction": "fee_waiver",
|
813
|
-
}
|
814
|
-
|
815
|
-
|
816
|
-
def test_evalute_rule_with_clinical_trial_eligibility_screener_rule():
|
817
|
-
clinical_trial_eligibility_screener_path = (
|
818
|
-
Path(__file__).parent / "test_data" / "clinical_trial_eligibility_screener.json"
|
819
|
-
)
|
820
|
-
with open(clinical_trial_eligibility_screener_path, "r", encoding="utf-8") as f:
|
821
|
-
jdm_dict = json.load(f)
|
822
|
-
|
823
|
-
input_data = {
|
824
|
-
"patient": {
|
825
|
-
"id": "P67890",
|
826
|
-
"name": "John Smith",
|
827
|
-
"age": 68,
|
828
|
-
"diagnosis": "lung_cancer",
|
829
|
-
"diseaseStage": "IV",
|
830
|
-
"currentMedications": ["immunosuppressants", "albuterol", "omeprazole"],
|
831
|
-
"priorTreatments": 3,
|
832
|
-
"comorbidities": ["autoimmune_disease", "COPD"],
|
833
|
-
"lastLabResults": {"wbc": 3.8, "hgb": 10.9, "plt": 150, "creatinine": 1.2},
|
834
|
-
}
|
835
|
-
}
|
836
|
-
|
837
|
-
result = evaluate_rule(JDMGraph.model_validate(jdm_dict), input_data)
|
838
|
-
|
839
|
-
expected_result = {
|
840
|
-
"decisionSummary": "Patient is not eligible for clinical trial",
|
841
|
-
"eligibilityReasons": [
|
842
|
-
{
|
843
|
-
"flag": False,
|
844
|
-
"reason": "Stage IV patients excluded from trial",
|
845
|
-
},
|
846
|
-
{
|
847
|
-
"flag": True,
|
848
|
-
"reason": "Diagnosis matches trial criteria",
|
849
|
-
},
|
850
|
-
{
|
851
|
-
"flag": True,
|
852
|
-
"reason": "Age within eligible range",
|
853
|
-
},
|
854
|
-
{
|
855
|
-
"flag": False,
|
856
|
-
"reason": "Excluded comorbidity present",
|
857
|
-
},
|
858
|
-
{
|
859
|
-
"flag": False,
|
860
|
-
"reason": "Patient taking excluded medications",
|
861
|
-
},
|
862
|
-
{
|
863
|
-
"flag": False,
|
864
|
-
"reason": "Too many prior treatments",
|
865
|
-
},
|
866
|
-
],
|
867
|
-
"failedCriteria": [
|
868
|
-
"stage",
|
869
|
-
"comorbidity",
|
870
|
-
"medication",
|
871
|
-
"priorTreatment",
|
872
|
-
],
|
873
|
-
"isEligible": False,
|
874
|
-
}
|
875
|
-
|
876
|
-
result = cast(EvaluateResponse, result)
|
877
|
-
|
878
|
-
def sort_reasons(reasons: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
879
|
-
return sorted(reasons, key=itemgetter("reason"))
|
880
|
-
|
881
|
-
assert result.success
|
882
|
-
assert result.result["decisionSummary"] == expected_result["decisionSummary"]
|
883
|
-
assert sort_reasons(result.result["eligibilityReasons"]) == sort_reasons(
|
884
|
-
expected_result["eligibilityReasons"]
|
885
|
-
)
|
886
|
-
assert sorted(result.result["failedCriteria"]) == sorted(
|
887
|
-
expected_result["failedCriteria"]
|
888
|
-
)
|
889
|
-
assert result.result["isEligible"] == expected_result["isEligible"]
|
890
|
-
|
891
|
-
|
892
|
-
def test_evalute_rule_with_customer_lifetime_value_rule():
|
893
|
-
customer_lifetime_value_path = (
|
894
|
-
Path(__file__).parent / "test_data" / "customer_lifetime_value.json"
|
895
|
-
)
|
896
|
-
with open(customer_lifetime_value_path, "r", encoding="utf-8") as f:
|
897
|
-
jdm_dict = json.load(f)
|
898
|
-
|
899
|
-
input_data = {
|
900
|
-
"customer": {
|
901
|
-
"id": "CUST-12345",
|
902
|
-
"name": "John Doe",
|
903
|
-
"segment": "retail",
|
904
|
-
"acquisitionCost": 150,
|
905
|
-
"acquisitionChannel": "paid_search",
|
906
|
-
},
|
907
|
-
"purchaseHistory": {
|
908
|
-
"orderValues": [120, 89, 245, 78, 310],
|
909
|
-
"customerDurationMonths": 18,
|
910
|
-
"averageGrossMarginPercent": 35,
|
911
|
-
"retentionRate": 85,
|
912
|
-
},
|
913
|
-
}
|
914
|
-
|
915
|
-
result = evaluate_rule(JDMGraph.model_validate(jdm_dict), input_data)
|
916
|
-
|
917
|
-
assert result.success
|
918
|
-
assert cast(EvaluateResponse, result).result == {
|
919
|
-
"acquisitionCostRatio": 0.009543603664743808,
|
920
|
-
"adjustedLTV": 15567.333333333334,
|
921
|
-
"averageOrderValue": 168.4,
|
922
|
-
"basicLTV": 15717.333333333334,
|
923
|
-
"customer": {
|
924
|
-
"acquisitionChannel": "paid_search",
|
925
|
-
"acquisitionCost": 150,
|
926
|
-
"id": "CUST-12345",
|
927
|
-
"name": "John Doe",
|
928
|
-
"segment": "retail",
|
929
|
-
},
|
930
|
-
"customerInsights": {
|
931
|
-
"recommendedStrategy": "High-touch service, premium offers, exclusive events",
|
932
|
-
"tier": "platinum",
|
933
|
-
},
|
934
|
-
"customerLifetimeMonths": 80,
|
935
|
-
"grossMargin": 0.35,
|
936
|
-
"purchaseFrequency": 3.3333333333333335,
|
937
|
-
"purchaseHistory": {
|
938
|
-
"averageGrossMarginPercent": 35,
|
939
|
-
"customerDurationMonths": 18,
|
940
|
-
"orderValues": [120, 89, 245, 78, 310],
|
941
|
-
"retentionRate": 85,
|
942
|
-
},
|
943
|
-
}
|
944
|
-
|
945
|
-
|
946
|
-
def test_evaluate_rule_with_supply_chain_risk_assessment_rule():
|
947
|
-
supply_chain_risk_assessment_path = (
|
948
|
-
Path(__file__).parent / "test_data" / "supply_chain_risk.json"
|
949
|
-
)
|
950
|
-
with open(supply_chain_risk_assessment_path, "r", encoding="utf-8") as f:
|
951
|
-
jdm_dict = json.load(f)
|
952
|
-
|
953
|
-
input_data = {
|
954
|
-
"supplier": {
|
955
|
-
"name": "GlobalTech Supplies Inc.",
|
956
|
-
"location": "medium_risk_region",
|
957
|
-
"performanceScore": 82,
|
958
|
-
"alternateSourcesCount": 2,
|
959
|
-
"products": [
|
960
|
-
{"id": "P123", "name": "Semiconductor Chip", "criticalComponent": True}
|
961
|
-
],
|
962
|
-
"relationshipDurationMonths": 36,
|
963
|
-
},
|
964
|
-
"geopoliticalTensions": True,
|
965
|
-
"marketVolatility": "medium",
|
966
|
-
"supplyCategory": "electronics",
|
967
|
-
"leadTimeData": {"averageDays": 45, "historicalVariance": 8},
|
968
|
-
}
|
969
|
-
|
970
|
-
result = evaluate_rule(JDMGraph.model_validate(jdm_dict), input_data)
|
971
|
-
|
972
|
-
assert result.success
|
973
|
-
assert cast(EvaluateResponse, result).result == {
|
974
|
-
"adjustedRiskScore": 57,
|
975
|
-
"assessment": {
|
976
|
-
"baseRiskCategory": "medium",
|
977
|
-
"baseRiskScore": 40,
|
978
|
-
"recommendedAction": "Monitor supplier performance and conduct quarterly reviews",
|
979
|
-
},
|
980
|
-
"finalAssessment": {
|
981
|
-
"leadTimeImpact": "minor",
|
982
|
-
"priorityLevel": "medium",
|
983
|
-
"riskCategory": "medium",
|
984
|
-
},
|
985
|
-
"geopoliticalFactor": 1.3,
|
986
|
-
"geopoliticalTensions": True,
|
987
|
-
"leadTimeData": {"averageDays": 45, "historicalVariance": 8},
|
988
|
-
"marketVolatility": "medium",
|
989
|
-
"marketVolatilityFactor": 1.1,
|
990
|
-
"supplier": {
|
991
|
-
"alternateSourcesCount": 2,
|
992
|
-
"location": "medium_risk_region",
|
993
|
-
"name": "GlobalTech Supplies Inc.",
|
994
|
-
"performanceScore": 82,
|
995
|
-
"products": [
|
996
|
-
{"criticalComponent": True, "id": "P123", "name": "Semiconductor Chip"}
|
997
|
-
],
|
998
|
-
"relationshipDurationMonths": 36,
|
999
|
-
},
|
1000
|
-
"supplyCategory": "electronics",
|
1001
|
-
}
|
1002
|
-
|
1003
|
-
|
1004
|
-
def test_evaluate_rule_with_import_duties_calculator_rule():
|
1005
|
-
import_duties_calculator_path = (
|
1006
|
-
Path(__file__).parent / "test_data" / "import_duties_calculator.json"
|
1007
|
-
)
|
1008
|
-
with open(import_duties_calculator_path, "r", encoding="utf-8") as f:
|
1009
|
-
jdm_dict = json.load(f)
|
1010
|
-
|
1011
|
-
input_data = {
|
1012
|
-
"product": {
|
1013
|
-
"category": "electronics",
|
1014
|
-
"value": 1200,
|
1015
|
-
"weight": 0.8,
|
1016
|
-
"hsCode": "851712",
|
1017
|
-
},
|
1018
|
-
"origin": {"country": "CN", "hasFTA": False, "preferentialTreatment": False},
|
1019
|
-
"destination": {"country": "US"},
|
1020
|
-
}
|
1021
|
-
|
1022
|
-
result = evaluate_rule(JDMGraph.model_validate(jdm_dict), input_data)
|
1023
|
-
|
1024
|
-
assert result.success
|
1025
|
-
assert cast(EvaluateResponse, result).result == {
|
1026
|
-
"additionalFees": 0,
|
1027
|
-
"baseDuty": 180,
|
1028
|
-
"countryAdjustment": 225,
|
1029
|
-
"dutyRate": 0.1875,
|
1030
|
-
"minDuty": 225,
|
1031
|
-
"preferentialDiscount": 225,
|
1032
|
-
"totalDuty": 225,
|
1033
|
-
}
|
1034
|
-
|
1035
|
-
|
1036
|
-
def test_evaluate_rule_with_cellular_data_rollover_system_rule():
|
1037
|
-
cellular_data_rollover_system_path = (
|
1038
|
-
Path(__file__).parent / "test_data" / "cellular_data_rollover_system.json"
|
1039
|
-
)
|
1040
|
-
with open(cellular_data_rollover_system_path, "r", encoding="utf-8") as f:
|
1041
|
-
jdm_dict = json.load(f)
|
1042
|
-
|
1043
|
-
input_data = {
|
1044
|
-
"plan": {
|
1045
|
-
"type": "premium",
|
1046
|
-
"monthlyDataAllowance": 50,
|
1047
|
-
"rolloverEligible": True,
|
1048
|
-
},
|
1049
|
-
"currentBillingCycle": {
|
1050
|
-
"dataUsed": 35,
|
1051
|
-
"consecutiveRollovers": 1,
|
1052
|
-
"rolloverData": 5,
|
1053
|
-
},
|
1054
|
-
}
|
1055
|
-
|
1056
|
-
result = evaluate_rule(JDMGraph.model_validate(jdm_dict), input_data)
|
1057
|
-
|
1058
|
-
assert result.success
|
1059
|
-
assert cast(EvaluateResponse, result).result == {
|
1060
|
-
"nextBillingCycle": {"consecutiveRollovers": 2, "status": "approved"},
|
1061
|
-
"responseMessage": "Rollover successful. You have 20 GB of rollover data available for your next billing cycle.",
|
1062
|
-
}
|
1063
|
-
|
1064
|
-
|
1065
|
-
def test_evaluate_rule_with_online_check_in_eligibility_system_rule():
|
1066
|
-
online_check_in_eligibility_system_path = (
|
1067
|
-
Path(__file__).parent / "test_data" / "online_check_in_eligibility_system.json"
|
1068
|
-
)
|
1069
|
-
with open(online_check_in_eligibility_system_path, "r", encoding="utf-8") as f:
|
1070
|
-
jdm_dict = json.load(f)
|
1071
|
-
|
1072
|
-
input_data = {
|
1073
|
-
"passenger": {
|
1074
|
-
"id": "P12345678",
|
1075
|
-
"name": "John Smith",
|
1076
|
-
"hasValidPassport": True,
|
1077
|
-
"hasValidVisa": True,
|
1078
|
-
"requiresSpecialAssistance": False,
|
1079
|
-
"frequentFlyerStatus": "gold",
|
1080
|
-
},
|
1081
|
-
"flight": {
|
1082
|
-
"flightNumber": "BA123",
|
1083
|
-
"departureTime": "2025-03-20T10:30:00Z",
|
1084
|
-
"origin": "LHR",
|
1085
|
-
"destination": "JFK",
|
1086
|
-
"requiresVisa": True,
|
1087
|
-
"hasSeatSelection": True,
|
1088
|
-
"allowsExtraBaggage": True,
|
1089
|
-
"hasSpecialMealOptions": True,
|
1090
|
-
},
|
1091
|
-
}
|
1092
|
-
|
1093
|
-
result = evaluate_rule(JDMGraph.model_validate(jdm_dict), input_data)
|
1094
|
-
|
1095
|
-
assert result.success
|
1096
|
-
assert cast(EvaluateResponse, result).result == {
|
1097
|
-
"canAddBaggage": True,
|
1098
|
-
"canSelectSeat": True,
|
1099
|
-
"isEligible": False,
|
1100
|
-
"message": "You are eligible for online check-in.",
|
1101
|
-
"statusCode": "eligible",
|
1102
|
-
}
|
1103
|
-
|
1104
|
-
|
1105
|
-
def test_evaluate_rule_with_warehouse_cross_docking_rule():
|
1106
|
-
warehouse_cross_docking_path = (
|
1107
|
-
Path(__file__).parent / "test_data" / "warehouse_cross_docking.json"
|
1108
|
-
)
|
1109
|
-
with open(warehouse_cross_docking_path, "r", encoding="utf-8") as f:
|
1110
|
-
jdm_dict = json.load(f)
|
1111
|
-
|
1112
|
-
input_data = {
|
1113
|
-
"inboundShipmentId": "IN-12345",
|
1114
|
-
"inboundShipmentTime": "2025-03-19T10:00:00Z",
|
1115
|
-
"outboundShipmentTime": "2025-03-20T09:00:00Z",
|
1116
|
-
"matchingOutboundOrders": [
|
1117
|
-
{
|
1118
|
-
"orderId": "ORD-789",
|
1119
|
-
"customerPriority": "standard",
|
1120
|
-
"destinationZone": "East",
|
1121
|
-
},
|
1122
|
-
{
|
1123
|
-
"orderId": "ORD-790",
|
1124
|
-
"customerPriority": "premium",
|
1125
|
-
"destinationZone": "East",
|
1126
|
-
},
|
1127
|
-
],
|
1128
|
-
"inboundShipmentItems": [
|
1129
|
-
{"sku": "ITEM-001", "quantity": 50, "category": "Electronics"},
|
1130
|
-
{"sku": "ITEM-002", "quantity": 30, "category": "Home Goods"},
|
1131
|
-
],
|
1132
|
-
"currentStorageUsed": 7500,
|
1133
|
-
"totalStorageCapacity": 10000,
|
1134
|
-
"crossDockingBayAssignment": "Bay-E4",
|
1135
|
-
}
|
1136
|
-
|
1137
|
-
result = evaluate_rule(JDMGraph.model_validate(jdm_dict), input_data)
|
1138
|
-
|
1139
|
-
assert result.success
|
1140
|
-
assert cast(EvaluateResponse, result).result == {
|
1141
|
-
"crossDockDecision": "cross-dock",
|
1142
|
-
"crossDockingBayAssignment": "Bay-E4",
|
1143
|
-
"currentStorageUsed": 7500,
|
1144
|
-
"decisionReason": "Matching orders available within 48 hours",
|
1145
|
-
"dockingBay": "Bay-E4",
|
1146
|
-
"estimatedProcessingTime": 30,
|
1147
|
-
"hasMatchingOutboundOrders": True,
|
1148
|
-
"inboundShipmentId": "IN-12345",
|
1149
|
-
"inboundShipmentItems": [
|
1150
|
-
{"category": "Electronics", "quantity": 50, "sku": "ITEM-001"},
|
1151
|
-
{"category": "Home Goods", "quantity": 30, "sku": "ITEM-002"},
|
1152
|
-
],
|
1153
|
-
"inboundShipmentTime": "2025-03-19T10:00:00Z",
|
1154
|
-
"matchingOutboundOrders": [
|
1155
|
-
{
|
1156
|
-
"customerPriority": "standard",
|
1157
|
-
"destinationZone": "East",
|
1158
|
-
"orderId": "ORD-789",
|
1159
|
-
},
|
1160
|
-
{
|
1161
|
-
"customerPriority": "premium",
|
1162
|
-
"destinationZone": "East",
|
1163
|
-
"orderId": "ORD-790",
|
1164
|
-
},
|
1165
|
-
],
|
1166
|
-
"outboundShipmentTime": "2025-03-20T09:00:00Z",
|
1167
|
-
"priority": "normal",
|
1168
|
-
"timeDifferenceHours": 23,
|
1169
|
-
"totalStorageCapacity": 10000,
|
1170
|
-
"warehouseCapacityPercentage": 75,
|
1171
|
-
}
|
1172
|
-
|
1173
|
-
|
1174
|
-
def test_evaluate_rule_with_booking_fraud_detection_rule():
|
1175
|
-
booking_fraud_detection_path = (
|
1176
|
-
Path(__file__).parent / "test_data" / "booking_fraud_detection.json"
|
1177
|
-
)
|
1178
|
-
with open(booking_fraud_detection_path, "r", encoding="utf-8") as f:
|
1179
|
-
jdm_dict = json.load(f)
|
1180
|
-
|
1181
|
-
input_data = {
|
1182
|
-
"booking": {
|
1183
|
-
"payment_method": "prepaid_card",
|
1184
|
-
"amount": 2500,
|
1185
|
-
"ip_country": "US",
|
1186
|
-
},
|
1187
|
-
"account": {"country": "US", "bookings_last_24h": 6},
|
1188
|
-
}
|
1189
|
-
|
1190
|
-
result = evaluate_rule(JDMGraph.model_validate(jdm_dict), input_data)
|
1191
|
-
|
1192
|
-
assert result.success
|
1193
|
-
assert cast(EvaluateResponse, result).result == {
|
1194
|
-
"flags": {"manual_review": True, "requires_verification": True},
|
1195
|
-
}
|
1196
|
-
|
1197
|
-
|
1198
|
-
def test_evaluate_rule_with_applicant_risk_assessment_rule():
|
1199
|
-
applicant_risk_assessment_path = (
|
1200
|
-
Path(__file__).parent / "test_data" / "applicant_risk_assessment.json"
|
1201
|
-
)
|
1202
|
-
with open(applicant_risk_assessment_path, "r", encoding="utf-8") as f:
|
1203
|
-
jdm_dict = json.load(f)
|
1204
|
-
|
1205
|
-
input_data = {
|
1206
|
-
"applicant": {
|
1207
|
-
"creditScore": 710,
|
1208
|
-
"latePayments": 1,
|
1209
|
-
"creditHistoryMonths": 48,
|
1210
|
-
"employmentMonths": 36,
|
1211
|
-
"incomeVerification": "complete",
|
1212
|
-
"bankAccountStanding": "good",
|
1213
|
-
"debtToIncomeRatio": 0.35,
|
1214
|
-
"outstandingLoans": 2,
|
1215
|
-
}
|
1216
|
-
}
|
1217
|
-
|
1218
|
-
result = evaluate_rule(JDMGraph.model_validate(jdm_dict), input_data)
|
1219
|
-
|
1220
|
-
assert result.success
|
1221
|
-
assert cast(EvaluateResponse, result).result == {
|
1222
|
-
"applicant": {
|
1223
|
-
"bankAccountStanding": "good",
|
1224
|
-
"creditHistoryMonths": 48,
|
1225
|
-
"creditScore": 710,
|
1226
|
-
"debtToIncomeRatio": 0.35,
|
1227
|
-
"employmentMonths": 36,
|
1228
|
-
"incomeVerification": "complete",
|
1229
|
-
"latePayments": 1,
|
1230
|
-
"outstandingLoans": 2,
|
1231
|
-
},
|
1232
|
-
"approvalStatus": "manual-review",
|
1233
|
-
"interestRateModifier": 0,
|
1234
|
-
"negativeFactors": [],
|
1235
|
-
"negativeFactorsCount": 0,
|
1236
|
-
"riskCategory": "medium",
|
1237
|
-
"scores": {"creditHistory": 20, "debtToIncome": 15, "incomeStability": 20},
|
1238
|
-
"totalRiskScore": 55,
|
1239
|
-
}
|
1240
|
-
|
1241
|
-
|
1242
|
-
def test_evaluate_rule_with_insurance_prior_authorization_rule():
|
1243
|
-
insurance_prior_authorization_path = (
|
1244
|
-
Path(__file__).parent / "test_data" / "insurance_prior_authorization.json"
|
1245
|
-
)
|
1246
|
-
with open(insurance_prior_authorization_path, "r", encoding="utf-8") as f:
|
1247
|
-
jdm_dict = json.load(f)
|
1248
|
-
|
1249
|
-
input_data = {
|
1250
|
-
"patientInfo": {"insuranceType": "Commercial"},
|
1251
|
-
"diagnosisCodes": ["M54.5", "M51.26"],
|
1252
|
-
"serviceType": "Imaging",
|
1253
|
-
"serviceDetails": {
|
1254
|
-
"code": "70551",
|
1255
|
-
"cost": 1200,
|
1256
|
-
"isEmergency": False,
|
1257
|
-
},
|
1258
|
-
}
|
1259
|
-
|
1260
|
-
result = evaluate_rule(JDMGraph.model_validate(jdm_dict), input_data)
|
1261
|
-
|
1262
|
-
assert result.success
|
1263
|
-
assert cast(EvaluateResponse, result).result["requiresAuthorization"]
|
1264
|
-
assert (
|
1265
|
-
cast(EvaluateResponse, result).result["reason"]
|
1266
|
-
== "Advanced imaging requires prior authorization"
|
1267
|
-
)
|
1268
|
-
|
1269
|
-
|
1270
|
-
def test_evaluate_rule_with_portfolio_risk_monitor_rule():
|
1271
|
-
portfolio_risk_monitor_path = (
|
1272
|
-
Path(__file__).parent / "test_data" / "portfolio_risk_monitor.json"
|
1273
|
-
)
|
1274
|
-
with open(portfolio_risk_monitor_path, "r", encoding="utf-8") as f:
|
1275
|
-
jdm_dict = json.load(f)
|
1276
|
-
|
1277
|
-
input_data = {
|
1278
|
-
"customer": {
|
1279
|
-
"id": "cust-78945",
|
1280
|
-
"name": "John Smith",
|
1281
|
-
"riskTolerance": "moderate",
|
1282
|
-
"investmentHorizon": "long-term",
|
1283
|
-
"preferences": {
|
1284
|
-
"allowAutomaticAdjustments": True,
|
1285
|
-
"alertThreshold": "moderate",
|
1286
|
-
"communicationPreference": "email",
|
1287
|
-
},
|
1288
|
-
},
|
1289
|
-
"portfolio": {
|
1290
|
-
"id": "port-12345",
|
1291
|
-
"name": "Retirement Portfolio",
|
1292
|
-
"totalValue": 750000,
|
1293
|
-
"creationDate": "2019-05-12",
|
1294
|
-
"lastRebalance": 95,
|
1295
|
-
"volatility": 22.5,
|
1296
|
-
"highRiskPercentage": 35,
|
1297
|
-
"currentAllocation": {"equity": 65, "bonds": 25, "cash": 10},
|
1298
|
-
"targetAllocation": {"equity": 60, "bonds": 35, "cash": 5},
|
1299
|
-
"holdings": [
|
1300
|
-
{
|
1301
|
-
"symbol": "VTI",
|
1302
|
-
"category": "equity",
|
1303
|
-
"percentage": 30,
|
1304
|
-
"value": 225000,
|
1305
|
-
},
|
1306
|
-
{
|
1307
|
-
"symbol": "VXUS",
|
1308
|
-
"category": "equity",
|
1309
|
-
"percentage": 20,
|
1310
|
-
"value": 150000,
|
1311
|
-
},
|
1312
|
-
{
|
1313
|
-
"symbol": "VGT",
|
1314
|
-
"category": "equity",
|
1315
|
-
"percentage": 15,
|
1316
|
-
"value": 112500,
|
1317
|
-
},
|
1318
|
-
{
|
1319
|
-
"symbol": "BND",
|
1320
|
-
"category": "bonds",
|
1321
|
-
"percentage": 25,
|
1322
|
-
"value": 187500,
|
1323
|
-
},
|
1324
|
-
{
|
1325
|
-
"symbol": "CASH",
|
1326
|
-
"category": "cash",
|
1327
|
-
"percentage": 10,
|
1328
|
-
"value": 75000,
|
1329
|
-
},
|
1330
|
-
],
|
1331
|
-
},
|
1332
|
-
"market": {
|
1333
|
-
"volatilityIndex": 28.5,
|
1334
|
-
"trendPercentage": -12.5,
|
1335
|
-
"interestRate": 3.75,
|
1336
|
-
"sectorPerformance": {
|
1337
|
-
"technology": -15.2,
|
1338
|
-
"healthcare": -5.1,
|
1339
|
-
"financials": -18.4,
|
1340
|
-
"consumerStaples": -3.2,
|
1341
|
-
"utilities": 1.5,
|
1342
|
-
},
|
1343
|
-
"economicIndicators": {
|
1344
|
-
"gdpGrowth": 0.8,
|
1345
|
-
"inflation": 4.2,
|
1346
|
-
"unemploymentRate": 4.1,
|
1347
|
-
},
|
1348
|
-
},
|
1349
|
-
}
|
1350
|
-
|
1351
|
-
result = evaluate_rule(JDMGraph.model_validate(jdm_dict), input_data)
|
1352
|
-
|
1353
|
-
assert result.success
|
1354
|
-
|
1355
|
-
assert cast(EvaluateResponse, result).result["action"] == "rebalance"
|
1356
|
-
assert cast(EvaluateResponse, result).result["outcome"] == {
|
1357
|
-
"riskScore": 0.53,
|
1358
|
-
"status": "rebalance_suggested",
|
1359
|
-
"timestamp": cast(EvaluateResponse, result).result["outcome"]["timestamp"],
|
1360
|
-
}
|
1361
|
-
|
1362
|
-
assert cast(EvaluateResponse, result).result["rebalanceDetails"] == {
|
1363
|
-
"currentAllocation": {"bonds": 25, "cash": 10, "equity": 65},
|
1364
|
-
"customerId": "cust-78945",
|
1365
|
-
"date": cast(EvaluateResponse, result).result["rebalanceDetails"]["date"],
|
1366
|
-
"driftPercentage": "5.0",
|
1367
|
-
"message": "Rebalancing recommended: Portfolio has drifted 5.0% from target allocation.",
|
1368
|
-
"portfolioId": "port-12345",
|
1369
|
-
"riskCategory": "high",
|
1370
|
-
"riskScore": 0.53,
|
1371
|
-
"suggestedChanges": {"bonds": 10, "cash": -5, "equity": -5},
|
1372
|
-
"targetAllocation": {"bonds": 35, "cash": 5, "equity": 60},
|
1373
|
-
}
|
1374
|
-
|
1375
|
-
|
1376
|
-
def test_evaluate_rule_with_order_consolidation_system_rule():
|
1377
|
-
order_consolidation_system_path = (
|
1378
|
-
Path(__file__).parent / "test_data" / "order_consolidation_system.json"
|
1379
|
-
)
|
1380
|
-
with open(order_consolidation_system_path, "r", encoding="utf-8") as f:
|
1381
|
-
jdm_dict = json.load(f)
|
1382
|
-
|
1383
|
-
input_data = {
|
1384
|
-
"orders": [
|
1385
|
-
{
|
1386
|
-
"orderId": "ORD-12345",
|
1387
|
-
"customerName": "John Smith",
|
1388
|
-
"deliveryAddress": {
|
1389
|
-
"street": "123 Main St",
|
1390
|
-
"city": "Springfield",
|
1391
|
-
"state": "IL",
|
1392
|
-
"zipCode": "62704",
|
1393
|
-
"coordinates": {"latitude": 39.7817, "longitude": -89.6501},
|
1394
|
-
},
|
1395
|
-
"requestedDeliveryDate": "2025-03-25T14:00:00Z",
|
1396
|
-
"orderWeight": 12.5,
|
1397
|
-
"orderItems": 3,
|
1398
|
-
},
|
1399
|
-
{
|
1400
|
-
"orderId": "ORD-12346",
|
1401
|
-
"customerName": "Jane Doe",
|
1402
|
-
"deliveryAddress": {
|
1403
|
-
"street": "456 Oak Ave",
|
1404
|
-
"city": "Springfield",
|
1405
|
-
"state": "IL",
|
1406
|
-
"zipCode": "62702",
|
1407
|
-
"coordinates": {"latitude": 39.8021, "longitude": -89.6443},
|
1408
|
-
},
|
1409
|
-
"requestedDeliveryDate": "2025-03-25T16:00:00Z",
|
1410
|
-
"orderWeight": 8.2,
|
1411
|
-
"orderItems": 2,
|
1412
|
-
},
|
1413
|
-
],
|
1414
|
-
"distanceKm": 35,
|
1415
|
-
"deliveryWindowDifferenceHours": 24,
|
1416
|
-
"availableCarrierCapacity": 4,
|
1417
|
-
"orderWeight1": 12.5,
|
1418
|
-
"orderWeight2": 8.2,
|
1419
|
-
"carrierDetails": {
|
1420
|
-
"carrierId": "CAR-789",
|
1421
|
-
"maxCapacity": 500,
|
1422
|
-
"currentLoad": 320,
|
1423
|
-
},
|
1424
|
-
}
|
1425
|
-
|
1426
|
-
result = evaluate_rule(JDMGraph.model_validate(jdm_dict), input_data)
|
1427
|
-
|
1428
|
-
assert result.success
|
1429
|
-
|
1430
|
-
assert cast(EvaluateResponse, result).result["canConsolidate"]
|
1431
|
-
assert cast(EvaluateResponse, result).result["schedulingPriority"] == "high"
|
1432
|
-
assert cast(EvaluateResponse, result).result["availableCarrierCapacity"] == 4
|
1433
|
-
assert cast(EvaluateResponse, result).result["carrierDetails"] == {
|
1434
|
-
"carrierId": "CAR-789",
|
1435
|
-
"currentLoad": 320,
|
1436
|
-
"maxCapacity": 500,
|
1437
|
-
}
|
1438
|
-
assert (
|
1439
|
-
cast(EvaluateResponse, result).result["consolidationAction"]
|
1440
|
-
== "immediate_consolidation"
|
1441
|
-
)
|
1442
|
-
assert cast(EvaluateResponse, result).result["consolidationPriority"] == "high"
|
1443
|
-
assert cast(EvaluateResponse, result).result["consolidationWeight"] == 20.7
|
1444
|
-
assert cast(EvaluateResponse, result).result["costSavingEstimate"] == 23.75
|
1445
|
-
assert cast(EvaluateResponse, result).result["costSavingsReport"] == {
|
1446
|
-
"fuelSavings": 5.25,
|
1447
|
-
"laborSavings": 23.75,
|
1448
|
-
"totalSavings": 29,
|
1449
|
-
}
|
1450
|
-
|
1451
|
-
assert cast(EvaluateResponse, result).result["deliverySchedule"] == {
|
1452
|
-
"estimatedDeliveryTime": None,
|
1453
|
-
"notificationRequired": False,
|
1454
|
-
"type": "consolidated",
|
1455
|
-
}
|
1456
|
-
assert cast(EvaluateResponse, result).result["deliveryWindowDifferenceHours"] == 24
|
1457
|
-
assert cast(EvaluateResponse, result).result["distanceKm"] == 35
|
1458
|
-
assert cast(EvaluateResponse, result).result["expectedFuelSavings"] == 5.25
|
1459
|
-
assert (
|
1460
|
-
cast(EvaluateResponse, result).result["explanation"]
|
1461
|
-
== "Orders are nearby, delivery window compatible, and carrier has capacity"
|
1462
|
-
)
|
1463
|
-
assert cast(EvaluateResponse, result).result["orderWeight1"] == 12.5
|
1464
|
-
assert cast(EvaluateResponse, result).result["orderWeight2"] == 8.2
|
1465
|
-
assert cast(EvaluateResponse, result).result["orders"] == [
|
1466
|
-
{
|
1467
|
-
"customerName": "John Smith",
|
1468
|
-
"deliveryAddress": {
|
1469
|
-
"city": "Springfield",
|
1470
|
-
"coordinates": {"latitude": 39.7817, "longitude": -89.6501},
|
1471
|
-
"state": "IL",
|
1472
|
-
"street": "123 Main St",
|
1473
|
-
"zipCode": "62704",
|
1474
|
-
},
|
1475
|
-
"orderId": "ORD-12345",
|
1476
|
-
"orderItems": 3,
|
1477
|
-
"orderWeight": 12.5,
|
1478
|
-
"requestedDeliveryDate": "2025-03-25T14:00:00Z",
|
1479
|
-
},
|
1480
|
-
{
|
1481
|
-
"customerName": "Jane Doe",
|
1482
|
-
"deliveryAddress": {
|
1483
|
-
"city": "Springfield",
|
1484
|
-
"coordinates": {"latitude": 39.8021, "longitude": -89.6443},
|
1485
|
-
"state": "IL",
|
1486
|
-
"street": "456 Oak Ave",
|
1487
|
-
"zipCode": "62702",
|
1488
|
-
},
|
1489
|
-
"orderId": "ORD-12346",
|
1490
|
-
"orderItems": 2,
|
1491
|
-
"orderWeight": 8.2,
|
1492
|
-
"requestedDeliveryDate": "2025-03-25T16:00:00Z",
|
1493
|
-
},
|
1494
|
-
]
|