Phase 2 and 3 of the platform rework. - Neutral request/response contract: external_id, metadata, X-AI-Key, run_id in the response; client-specific fields removed. - AI profile is a revision table: saving creates an inactive revision, activation is separate, try-run does not store a run. One chat-completions gateway serves AIHub and OpenAI-compatible profiles. - Add between and in rule operators. - Contracts moved to contracts/lead-scoring/ with a score-response fixture. - Admin Web updated for revisions, runs and the neutral fixture. - Consolidate tests; remove rewrite_architect.md and the stale handoff doc; rewrite spec, CONTEXT and ARCHITECTURE for the current design. Not verified: live AIHub/OpenAI behavior, Omni/EMS integration.
119 lines
4.0 KiB
Python
119 lines
4.0 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(
|
|
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_missing_customer_fields_do_not_match_neq_or_null_and_unknown_operators_fail():
|
|
snapshot = {"lead_profile": {"custom_fields": {}}}
|
|
policy = ScoringConfiguration(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
|
|
|
|
def _matched(operator, value, current):
|
|
snapshot = {"lead_profile": {"custom_fields": {"x": current}}}
|
|
policy = ScoringConfiguration(
|
|
rules=(Rule("r", "custom_fields.x", operator, value, 10),), rule_weight=1.0, ai_weight=0.0
|
|
)
|
|
return score_rules(snapshot, policy)[1][0]["matched"]
|
|
|
|
|
|
def test_between_is_inclusive_and_numeric_only():
|
|
assert [_matched("between", [3, 10], v) for v in (3, 5.5, 10)] == [True] * 3
|
|
assert [_matched("between", [3, 10], v) for v in (2.99, 10.01, "5", True, None, [5])] == [
|
|
False
|
|
] * 6
|
|
|
|
|
|
def test_in_matches_whole_values_only():
|
|
assert _matched("in", ["hot", "warm"], "hot")
|
|
assert not _matched("in", ["hot"], "not_hot")
|
|
assert not _matched("in", ["hot"], ["hot"])
|
|
assert _matched("in", [1, 2], 2.0)
|
|
assert not _matched("in", [1], True)
|