dotb_ai-platform/tests/test_domain_scoring.py
hungtran24 6577bb74be refactor: simplify scoring service, AI profile revisions and test suite
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.
2026-10-09 08:23:21 +07:00

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)