import pytest from pydantic import ValidationError from lead_scoring.config import Settings from lead_scoring.scoring import ( EffortProfile, FallbackPolicy, ScoringConfig, combine_confidence, combine_final_score, ) def test_scoring_config_default_weights_and_ai_fields(): config = ScoringConfig( version="config-1", confidence=0.8, confidence_threshold=0.8, hitl_policy_version="policy-1", ) assert config.rule_weight == 0.4 assert config.ai_weight == 0.6 assert config.effort_profile == EffortProfile.MEDIUM assert config.ai_model == "openai/gpt-4o-mini" assert config.fallback_policy == FallbackPolicy.RULES_ONLY_REVIEW assert config.ai_timeout_seconds == 10.0 def test_scoring_config_rejects_unknown_effort_profile(): with pytest.raises(ValidationError): ScoringConfig.model_validate( { "version": "config-bad-effort", "confidence": 0.8, "confidence_threshold": 0.8, "hitl_policy_version": "policy-1", "effort_profile": "unsupported_profile", } ) def test_scoring_config_rejects_unknown_fallback_policy(): with pytest.raises(ValidationError): ScoringConfig.model_validate( { "version": "config-bad-fallback", "confidence": 0.8, "confidence_threshold": 0.8, "hitl_policy_version": "policy-1", "fallback_policy": "unknown_fallback_policy", } ) def test_scoring_config_validates_weights_sum_to_one(): # Valid explicit weights config = ScoringConfig( version="config-2", confidence=0.8, confidence_threshold=0.8, hitl_policy_version="policy-1", rule_weight=0.3, ai_weight=0.7, ) assert config.rule_weight == 0.3 assert config.ai_weight == 0.7 # Invalid weights sum with pytest.raises(ValidationError) as exc_info: ScoringConfig( version="config-3", confidence=0.8, confidence_threshold=0.8, hitl_policy_version="policy-1", rule_weight=0.5, ai_weight=0.6, ) assert "must equal 1.0" in str(exc_info.value) def test_settings_validate_worker_lease_covers_ai_timeout(): # 2 * 10s + 5s = 25s required. worker_lease_seconds=20s must fail. with pytest.raises(ValidationError) as exc_info: Settings( worker_lease_seconds=20.0, scoring_configs=[ ScoringConfig( version="cfg-long-ai", confidence=0.8, confidence_threshold=0.8, hitl_policy_version="policy-1", ai_timeout_seconds=10.0, ) ], ) assert "must exceed two-pass AI timeout plus margin" in str(exc_info.value) # Light profile (1 pass): 1 * 10s + 5s = 15s required. worker_lease_seconds=12s must fail. with pytest.raises(ValidationError) as exc_info: Settings( worker_lease_seconds=12.0, scoring_configs=[ ScoringConfig( version="cfg-light-lease", confidence=0.8, confidence_threshold=0.8, hitl_policy_version="policy-1", effort_profile=EffortProfile.LIGHT, ai_timeout_seconds=10.0, ) ], ) assert "must exceed one-pass AI timeout plus margin" in str(exc_info.value) # Heavy profile (3 passes): 3 * 10s + 5s = 35s required. worker_lease_seconds=30s must fail. with pytest.raises(ValidationError) as exc_info: Settings( worker_lease_seconds=30.0, scoring_configs=[ ScoringConfig( version="cfg-heavy-lease", confidence=0.8, confidence_threshold=0.8, hitl_policy_version="policy-1", effort_profile=EffortProfile.HEAVY, ai_timeout_seconds=10.0, ) ], ) assert "must exceed three-pass AI timeout plus margin" in str(exc_info.value) def test_settings_reads_aihub_base_url_from_env(monkeypatch: pytest.MonkeyPatch): monkeypatch.setenv("AIHUB_BASE_URL", "https://aihub-custom.internal.dotb.vn") settings = Settings() assert settings.aihub_base_url == "https://aihub-custom.internal.dotb.vn" def test_settings_validates_aihub_base_url_shapes_and_environment(): # 1. Dev/Test accepts localhost HTTP s_dev = Settings(environment="development", aihub_base_url="http://localhost:8080") assert s_dev.aihub_base_url == "http://localhost:8080" s_127 = Settings(environment="development", aihub_base_url="http://127.0.0.1:8080") assert s_127.aihub_base_url == "http://127.0.0.1:8080" # 2. Dev/Test rejects remote HTTP with pytest.raises(ValidationError, match="HTTP scheme is only allowed for localhost"): Settings(environment="development", aihub_base_url="http://untrusted-remote.com") # 3. Production requires HTTPS s_prod = Settings(environment="production", aihub_base_url="https://aihub.internal") assert s_prod.aihub_base_url == "https://aihub.internal" with pytest.raises(ValidationError, match="must use HTTPS in production"): Settings(environment="production", aihub_base_url="http://localhost:8080") with pytest.raises(ValidationError, match="must use HTTPS in production"): Settings(environment="production", aihub_base_url="http://aihub.internal") # 4. Rejects user credentials in URL with pytest.raises(ValidationError, match="user credentials"): Settings(aihub_base_url="https://user:pass@aihub.internal") # 5. Rejects query parameters with pytest.raises(ValidationError, match="query parameters"): Settings(aihub_base_url="https://aihub.internal?env=prod") # 6. Rejects fragments with pytest.raises(ValidationError, match="fragments"): Settings(aihub_base_url="https://aihub.internal#anchor") def test_combine_final_score_deterministic_and_clamped(): # 80 * 0.4 + 90 * 0.6 = 32 + 54 = 86 assert combine_final_score(80, 90, 0.4, 0.6) == 86 # 100 * 0.5 + 0 * 0.5 = 50 assert combine_final_score(100, 0, 0.5, 0.5) == 50 # Bounds clamping [0, 100] assert combine_final_score(0, 0, 0.4, 0.6) == 0 assert combine_final_score(100, 100, 0.4, 0.6) == 100 def test_combine_confidence_deterministic_and_clamped(): # 0.8 * 0.4 + 0.9 * 0.6 = 0.32 + 0.54 = 0.86 assert combine_confidence(0.8, 0.9, 0.4, 0.6) == 0.86 # Clamping bounds [0.0, 1.0] assert combine_confidence(0.0, 0.0, 0.4, 0.6) == 0.0 assert combine_confidence(1.0, 1.0, 0.4, 0.6) == 1.0