dotb_ai-platform/tests/test_dynamic_rules.py
2026-09-14 09:57:12 +07:00

466 lines
15 KiB
Python

import pytest
from lead_scoring.schemas.dynamic_rule import (
DynamicRule,
DynamicRuleOperator,
DynamicRuleTarget,
)
from lead_scoring.schemas.scoring_config import ScoringConfig
from lead_scoring.scoring import (
RuleEvaluationStatus,
evaluate_education_rules,
)
from test_configurable_demo_backend import make_education_snapshot
def test_dynamic_rule_operators():
snap = make_education_snapshot(
{
"student_age": 12,
"budget": 6000000,
"branch": "Quan 3, TP. Ho Chi Minh",
"urgency": "high",
},
conversations=[
{
"conversation_id": "conv-1",
"channel": "zalo",
"messages": [
{
"message_id": "m1",
"sender_type": "contact",
"sent_at": "2026-08-28T11:00:00Z",
"content": "Toi can tim lop tieng Anh gap",
}
],
}
],
)
rules = (
DynamicRule(
id="rule_budget_gt",
name="Ngân sách trên 5 triệu",
target=DynamicRuleTarget.CUSTOM_FIELD,
custom_field_key="budget",
operator=DynamicRuleOperator.GT,
value=5000000,
points=15,
),
DynamicRule(
id="rule_branch_contains",
name="Cơ sở Quận 3",
target=DynamicRuleTarget.CUSTOM_FIELD,
custom_field_key="branch",
operator=DynamicRuleOperator.CONTAINS,
value="Quận 3",
points=10,
),
DynamicRule(
id="rule_urgency_in",
name="Mức độ cấp bách cao hoặc khẩn",
target=DynamicRuleTarget.CUSTOM_FIELD,
custom_field_key="urgency",
operator=DynamicRuleOperator.IN,
value=["high", "urgent"],
points=10,
),
DynamicRule(
id="rule_age_gte",
name="Tuổi từ 10 trở lên",
target=DynamicRuleTarget.STUDENT_AGE,
operator=DynamicRuleOperator.GTE,
value=10,
points=5,
),
)
config = ScoringConfig(
version="dynamic-test-v1",
base_score=0,
confidence=0.85,
confidence_threshold=0.8,
hitl_policy_version="1.0.0",
education_demo_enabled=True,
dynamic_rules=rules,
)
score, breakdown, _, _ = evaluate_education_rules(snap, config)
# Fixed rules:
# student_age=12 -> age_primary (40)
# recency -> hot (40)
# lead_source -> missing (0)
# Fixed subtotal = 80
# Dynamic rules:
# budget (15) + branch (10) + urgency (10) + age_gte (5) = 40
# Total = 80 + 40 = 120 -> clamped to 100
assert score == 100
dyn_items = [b for b in breakdown if b.rule_name.startswith("rule_")]
assert len(dyn_items) == 4
assert all(item.status == RuleEvaluationStatus.MATCHED for item in dyn_items)
assert sum(item.points_awarded for item in dyn_items) == 40
def test_dynamic_rule_missing_and_disabled():
snap = make_education_snapshot(
{"student_age": 12},
conversations=[],
)
rules = (
# Missing custom field
DynamicRule(
id="rule_missing_vip",
name="Khách VIP",
target=DynamicRuleTarget.CUSTOM_FIELD,
custom_field_key="vip_status",
operator=DynamicRuleOperator.EQ,
value="gold",
points=20,
),
# Disabled rule
DynamicRule(
id="rule_disabled",
name="Luật tạm dừng",
target=DynamicRuleTarget.STUDENT_AGE,
operator=DynamicRuleOperator.EQ,
value=12,
points=30,
enabled=False,
),
)
config = ScoringConfig(
version="dynamic-test-missing-v1",
base_score=0,
confidence=0.85,
confidence_threshold=0.8,
hitl_policy_version="1.0.0",
education_demo_enabled=True,
dynamic_rules=rules,
)
score, breakdown, _, _ = evaluate_education_rules(snap, config)
missing_item = next(b for b in breakdown if b.rule_name == "rule_missing_vip")
assert missing_item.status == RuleEvaluationStatus.SKIPPED_MISSING_DATA
assert missing_item.points_awarded == 0
disabled_item = next(b for b in breakdown if b.rule_name == "rule_disabled")
assert disabled_item.status == RuleEvaluationStatus.NOT_MATCHED
assert disabled_item.points_awarded == 0
def test_rule_overrides_validation():
import pytest
from pydantic import ValidationError
from lead_scoring.schemas.scoring_run import CreateScoringRunRequest
base_snapshot = make_education_snapshot({"student_age": 12, "course_interest": "English"})
# 1. Duplicate rule ID
with pytest.raises(ValidationError, match="Duplicate dynamic rule ID"):
CreateScoringRunRequest(
tenant_id="tenant_1",
subject_ref=base_snapshot.lead_profile.ems_lead_id,
scoring_config_version="1.0.0",
rule_overrides=[
DynamicRule(
id="rule_1",
name="Rule 1",
target=DynamicRuleTarget.STUDENT_AGE,
operator=DynamicRuleOperator.EQ,
value=12,
points=10,
),
DynamicRule(
id="rule_1",
name="Rule 1 Duplicate",
target=DynamicRuleTarget.STUDENT_AGE,
operator=DynamicRuleOperator.EQ,
value=12,
points=20,
),
],
snapshot=base_snapshot,
)
# 2. Custom field without custom_field_key
with pytest.raises(
ValidationError, match="custom_field_key is required when target is 'custom_field'"
):
CreateScoringRunRequest(
tenant_id="tenant_1",
subject_ref=base_snapshot.lead_profile.ems_lead_id,
scoring_config_version="1.0.0",
rule_overrides=[
DynamicRule(
id="rule_cf",
name="Missing Key",
target=DynamicRuleTarget.CUSTOM_FIELD,
custom_field_key="",
operator=DynamicRuleOperator.EQ,
value="test",
points=10,
)
],
snapshot=base_snapshot,
)
# 3. String value > 1000 chars
with pytest.raises(ValidationError, match="exceeds maximum allowed size"):
CreateScoringRunRequest(
tenant_id="tenant_1",
subject_ref=base_snapshot.lead_profile.ems_lead_id,
scoring_config_version="1.0.0",
rule_overrides=[
DynamicRule(
id="rule_long",
name="Long String",
target=DynamicRuleTarget.LEAD_SOURCE,
operator=DynamicRuleOperator.EQ,
value="x" * 1001,
points=10,
)
],
snapshot=base_snapshot,
)
# 4. List value > 100 items
with pytest.raises(ValidationError, match="exceeds maximum allowed length"):
CreateScoringRunRequest(
tenant_id="tenant_1",
subject_ref=base_snapshot.lead_profile.ems_lead_id,
scoring_config_version="1.0.0",
rule_overrides=[
DynamicRule(
id="rule_list_long",
name="Long List",
target=DynamicRuleTarget.LEAD_SOURCE,
operator=DynamicRuleOperator.IN,
value=["item"] * 101,
points=10,
)
],
snapshot=base_snapshot,
)
# 5. Over 30 rules
with pytest.raises(ValidationError):
CreateScoringRunRequest(
tenant_id="tenant_1",
subject_ref=base_snapshot.lead_profile.ems_lead_id,
scoring_config_version="1.0.0",
rule_overrides=[
DynamicRule(
id=f"rule_{i}",
name=f"Rule {i}",
target=DynamicRuleTarget.STUDENT_AGE,
operator=DynamicRuleOperator.EQ,
value=i,
points=1,
)
for i in range(31)
],
snapshot=base_snapshot,
)
def test_rule_overrides_overlay_and_replay():
snap = make_education_snapshot(
{
"student_age": 10,
"target_campus": "Disctrict 1",
"course_interest": "IELTS",
},
conversations=[
{
"conversation_id": "conv-overlay",
"channel": "zalo",
"messages": [
{
"message_id": "m1",
"sender_type": "contact",
"sent_at": "2026-08-28T11:00:00Z",
"content": "Toi can dang ky IELTS",
}
],
}
],
)
base_config = ScoringConfig(
version="base-overlay-v1",
base_score=10,
confidence=0.90,
confidence_threshold=0.80,
hitl_policy_version="1.0.0",
dynamic_rules=(),
)
overrides = [
DynamicRule(
id="rule_campus_d1",
name="Cơ sở Quận 1",
target=DynamicRuleTarget.CUSTOM_FIELD,
custom_field_key="target_campus",
operator=DynamicRuleOperator.CONTAINS,
value="District 1",
points=25,
),
DynamicRule(
id="rule_age_10",
name="Tuổi đúng 10",
target=DynamicRuleTarget.STUDENT_AGE,
operator=DynamicRuleOperator.EQ,
value=10,
points=15,
),
]
effective_config = base_config.model_copy(update={"dynamic_rules": tuple(overrides)})
assert len(effective_config.dynamic_rules) == 2
score, breakdown, _, _ = evaluate_education_rules(snap, effective_config)
age_bd = next(b for b in breakdown if b.rule_name == "rule_age_10")
assert age_bd.status == RuleEvaluationStatus.MATCHED
assert age_bd.points_awarded == 15
@pytest.mark.asyncio
async def test_api_rule_overrides_overlay_and_replay(temp_postgres_db: str):
import asyncio
from pathlib import Path
from alembic import command
from alembic.config import Config
from httpx import ASGITransport, AsyncClient
from pydantic import SecretStr
from lead_scoring.config import ServiceCallerConfig, Settings
from lead_scoring.main import create_app
from lead_scoring.schemas.scoring_config import ScoringConfig
root_dir = Path(__file__).resolve().parent.parent
ini_path = root_dir / "alembic.ini"
alembic_cfg = Config(str(ini_path))
alembic_cfg.attributes["sqlalchemy_url"] = temp_postgres_db
await asyncio.to_thread(command.upgrade, alembic_cfg, "head")
base_config = ScoringConfig(
version="overlay-demo-v1",
base_score=20,
rule_weight=1.0,
ai_weight=0.0,
confidence=0.90,
confidence_threshold=0.80,
hitl_policy_version="1.0.0",
dynamic_rules=(),
)
settings = Settings(
database_url=temp_postgres_db,
scoring_configs=[base_config],
tenant_allowlist=["tenant_dyn"],
service_callers=[
ServiceCallerConfig(
caller_id="test_caller",
token=SecretStr("valid_token"),
allowed_apps=["omni"],
allowed_features=["scoring"],
allowed_tenants=["tenant_dyn"],
)
],
)
app = create_app(settings=settings)
snapshot_data = {
"version": "1.0.0",
"scope": "linked_contact_history",
"captured_at": "2026-08-27T12:00:00Z",
"lead_profile": {
"ems_lead_id": "lead_overlay_1",
"name": "Student A",
"custom_fields": {
"course_interest": "Python Bootcamp",
"campus": "District 1",
},
},
"conversations": [],
}
rule_overrides = [
{
"id": "dyn_campus_d1",
"name": "District 1 Campus",
"target": "custom_field",
"custom_field_key": "campus",
"operator": "eq",
"value": "District 1",
"points": 30,
"enabled": True,
}
]
payload = {
"tenant_id": "tenant_dyn",
"subject_ref": "lead_overlay_1",
"scoring_config_version": "overlay-demo-v1",
"rule_overrides": rule_overrides,
"snapshot": snapshot_data,
}
headers = {
"Authorization": "Bearer valid_token",
"Idempotency-Key": "idem-overlay-101",
"X-DotB-App": "omni",
"X-DotB-Feature": "scoring",
}
async with app.router.lifespan_context(app):
async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as client:
# 1. Admit run with rule_overrides
resp = await client.post("/v1/scoring-runs", json=payload, headers=headers)
assert resp.status_code == 202
run_id = resp.json()["run_id"]
# 2. Wait for completion
get_resp = None
for _ in range(50):
await asyncio.sleep(0.1)
get_resp = await client.get(
f"/v1/scoring-runs/{run_id}",
headers={"Authorization": "Bearer valid_token", "X-Tenant-Id": "tenant_dyn"},
)
if get_resp.status_code == 200 and get_resp.json()["status"] in {
"completed",
"failed",
}:
break
assert get_resp is not None
run_data = get_resp.json()
assert run_data["status"] == "completed"
assert run_data["rule_score"] == 50 # 20 base + 30 rule
assert run_data["final_score"] == 50
assert run_data["breakdown"] is not None
assert any(b["rule_name"] == "dyn_campus_d1" for b in run_data["breakdown"])
# 3. Replay with identical payload and idempotency key
replay_resp = await client.post("/v1/scoring-runs", json=payload, headers=headers)
assert replay_resp.status_code == 202
assert replay_resp.json()["run_id"] == run_id
# 4. Check internal trace endpoint contains effective_config rule_breakdown
trace_resp = await client.get(
f"/v1/scoring-runs/{run_id}/trace",
headers={"Authorization": "Bearer valid_token", "X-Tenant-Id": "tenant_dyn"},
)
assert trace_resp.status_code == 200
trace_data = trace_resp.json()
assert any(b["rule_name"] == "dyn_campus_d1" for b in trace_data["rule_breakdown"])