145 lines
4.8 KiB
Python
145 lines
4.8 KiB
Python
from ai_platform.lead_scoring.domain.scoring import Rule, ScoringConfiguration, score_rules
|
|
|
|
|
|
def test_caller_supplied_rules_are_deterministic_and_bounded():
|
|
snapshot = {"lead_profile": {"custom_fields": {"region": "north", "age": 22}}}
|
|
policy = ScoringConfiguration(
|
|
version="omni-7",
|
|
rules=(
|
|
Rule("region", "custom_fields.region", "eq", "north", 65),
|
|
Rule("age", "custom_fields.age", "gte", 18, 20),
|
|
Rule("miss", "custom_fields.age", "gt", 30, 40),
|
|
),
|
|
rule_weight=1.0,
|
|
ai_weight=0.0,
|
|
)
|
|
first = score_rules(snapshot, policy)
|
|
assert first == score_rules(snapshot, policy)
|
|
assert first[0] == 85
|
|
assert [item["rule_id"] for item in first[1]] == ["region", "age", "miss"]
|
|
|
|
|
|
def test_contact_recency_uses_latest_instant_across_timezones_and_message_order():
|
|
base = {
|
|
"captured_at": "2025-09-25T10:00:00Z",
|
|
"lead_profile": {"custom_fields": {}},
|
|
"conversations": [
|
|
{
|
|
"messages": [
|
|
{"sender_type": "contact", "sent_at": "2025-09-25T08:00:00+02:00"},
|
|
{"sender_type": "contact", "sent_at": "2025-09-24T09:00:00Z"},
|
|
{"sender_type": "agent", "sent_at": "2025-09-25T09:59:00Z"},
|
|
]
|
|
}
|
|
],
|
|
}
|
|
policy = ScoringConfiguration(
|
|
version="omni-9",
|
|
rules=(Rule("recency", "metrics.contact_message_recency_days", "eq", 0.17, 1),),
|
|
rule_weight=1.0,
|
|
ai_weight=0.0,
|
|
)
|
|
|
|
assert score_rules(base, policy)[1][0]["matched"]
|
|
base["conversations"][0]["messages"].reverse()
|
|
assert score_rules(base, policy)[1][0]["matched"]
|
|
|
|
|
|
def test_contact_recency_is_none_without_contact_messages():
|
|
snapshot = {
|
|
"captured_at": "2025-09-25T10:00:00Z",
|
|
"lead_profile": {"custom_fields": {}},
|
|
"conversations": [
|
|
{"messages": [{"sender_type": "agent", "sent_at": "2025-09-25T09:00:00Z"}]}
|
|
],
|
|
}
|
|
policy = ScoringConfiguration(
|
|
version="omni-10",
|
|
rules=(Rule("missing", "metrics.contact_message_recency_days", "exists", None, 1),),
|
|
rule_weight=1.0,
|
|
ai_weight=0.0,
|
|
)
|
|
|
|
assert score_rules(snapshot, policy)[1][0]["matched"] is False
|
|
|
|
|
|
def test_missing_customer_fields_do_not_match_neq_or_null_and_unknown_operators_fail():
|
|
snapshot = {"lead_profile": {"custom_fields": {}}}
|
|
policy = ScoringConfiguration(
|
|
version="omni-11",
|
|
rules=(
|
|
Rule("neq-missing", "custom_fields.region", "ne", "north", 10),
|
|
Rule("null-missing", "custom_fields.region", "eq", None, 10),
|
|
Rule("unknown", "custom_fields.region", "unsupported", "north", 10),
|
|
),
|
|
rule_weight=1.0,
|
|
ai_weight=0.0,
|
|
)
|
|
|
|
score, breakdown = score_rules(snapshot, policy)
|
|
|
|
assert score == 0
|
|
assert all(not item["matched"] and item["points"] == 0 for item in breakdown)
|
|
|
|
|
|
def test_rule_comparisons_do_not_coerce_boolean_or_string_numbers():
|
|
snapshot = {
|
|
"lead_profile": {
|
|
"custom_fields": {
|
|
"flag": True,
|
|
"age_text": "18",
|
|
"tags": [True],
|
|
"nested": {"region": "north"},
|
|
}
|
|
}
|
|
}
|
|
policy = ScoringConfiguration(
|
|
version="omni-12",
|
|
rules=(
|
|
Rule("bool-eq-int", "custom_fields.flag", "eq", 1, 20),
|
|
Rule("bool-ne-int", "custom_fields.flag", "ne", 1, 20),
|
|
Rule("bool-gte", "custom_fields.flag", "gte", 1, 20),
|
|
Rule("string-gte-number", "custom_fields.age_text", "gte", 18, 20),
|
|
Rule("contains-int-in-bool-list", "custom_fields.tags", "contains", 1, 20),
|
|
Rule("nested-field", "custom_fields.nested.region", "eq", "north", 20),
|
|
Rule("int-eq-float", "metrics.conversation_count", "eq", 0.0, 20),
|
|
),
|
|
rule_weight=1.0,
|
|
ai_weight=0.0,
|
|
)
|
|
|
|
score, breakdown = score_rules(snapshot, policy)
|
|
|
|
assert score == 40
|
|
assert [item["matched"] for item in breakdown] == [
|
|
False,
|
|
False,
|
|
False,
|
|
False,
|
|
False,
|
|
True,
|
|
True,
|
|
]
|
|
|
|
|
|
def test_rule_points_are_bounded_after_all_customer_rules():
|
|
snapshot = {"lead_profile": {"custom_fields": {"match": True}}}
|
|
positive = ScoringConfiguration(
|
|
version="omni-13",
|
|
rules=(
|
|
Rule("a", "custom_fields.match", "eq", True, 100),
|
|
Rule("b", "custom_fields.match", "eq", True, 100),
|
|
),
|
|
rule_weight=1.0,
|
|
ai_weight=0.0,
|
|
)
|
|
negative = ScoringConfiguration(
|
|
version="omni-14",
|
|
rules=(Rule("a", "custom_fields.match", "eq", True, -100),),
|
|
rule_weight=1.0,
|
|
ai_weight=0.0,
|
|
)
|
|
|
|
assert score_rules(snapshot, positive)[0] == 100
|
|
assert score_rules(snapshot, negative)[0] == 0
|