test_rule_judgment_hard_gates.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130
  1. from __future__ import annotations
  2. from copy import deepcopy
  3. import pytest
  4. from content_agent.business_modules.rule_judgment.evaluator import decide
  5. from content_agent.run_service import RunService
  6. from content_agent.schemas import RunStartRequest
  7. from tests.p1_helpers import FakeQueryVariantClient, REAL_SOURCE_FIXTURE
  8. def test_hard_gate_outputs_rule_blocked_and_primary_reason(tmp_path):
  9. state = _state(tmp_path)
  10. bundle = deepcopy(state["evidence_bundles"][0])
  11. bundle["content"]["platform_content_id"] = ""
  12. bundle["source_evidence"] = {}
  13. decision = decide(state["run_id"], state["policy_run_id"], 1, bundle, state["policy_bundle"])
  14. assert decision["decision_action"] == "REJECT_CONTENT"
  15. assert decision["decision_reason_code"] == "missing_platform_content_id"
  16. assert decision["search_query_effect_status"] == "rule_blocked"
  17. assert set(decision["triggered_blocking_rules"]) >= {
  18. "missing_platform_content_id",
  19. "missing_source_evidence",
  20. }
  21. replay = decision["decision_replay_data"]
  22. assert replay["primary_gate_id"] == "missing_platform_content_id"
  23. assert replay["primary_reason_code"] == "missing_platform_content_id"
  24. assert replay["effect_mapping_id"] == "map_reject_rule_blocked"
  25. def test_unknown_hard_gate_operator_fails_fast(tmp_path):
  26. state = _state(tmp_path)
  27. policy_bundle = deepcopy(state["policy_bundle"])
  28. policy_bundle["rule_pack"]["hard_gates"][0]["when"]["op"] = "starts_with"
  29. with pytest.raises(ValueError, match="unsupported rule operator"):
  30. decide(
  31. state["run_id"],
  32. state["policy_run_id"],
  33. 1,
  34. state["evidence_bundles"][0],
  35. policy_bundle,
  36. )
  37. def test_legacy_senior_fit_field_no_longer_triggers_hard_gate(tmp_path):
  38. state = _state(tmp_path)
  39. bundle = deepcopy(state["evidence_bundles"][0])
  40. bundle["pattern_match_result"]["fit_senior_50plus"] = False
  41. decision = decide(state["run_id"], state["policy_run_id"], 1, bundle, state["policy_bundle"])
  42. assert "not_fit_senior" not in decision["triggered_blocking_rules"]
  43. assert decision["decision_reason_code"] != "content_not_fit_senior"
  44. assert decision["decision_reason_code"] != "content_pattern_recall_required"
  45. def test_hard_gate_action_is_taken_from_rule_config(tmp_path):
  46. # The judge_failed gate defaults to TECHNICAL_RETRY_REQUIRED; flipping its config to
  47. # REJECT_CONTENT must change the decision purely via config, with no code special-case.
  48. state = _state(tmp_path)
  49. bundle = deepcopy(state["evidence_bundles"][0])
  50. bundle["pattern_match_result"]["judge_status"] = "failed"
  51. decision = decide(state["run_id"], state["policy_run_id"], 1, bundle, state["policy_bundle"])
  52. assert decision["decision_action"] == "TECHNICAL_RETRY_REQUIRED"
  53. flipped = deepcopy(state["policy_bundle"])
  54. for gate in flipped["rule_pack"]["hard_gates"]:
  55. if gate["gate_id"] == "judge_failed":
  56. gate["decision_action"] = "REJECT_CONTENT"
  57. gate["severity"] = "fatal"
  58. decision = decide(state["run_id"], state["policy_run_id"], 1, bundle, flipped)
  59. assert decision["decision_action"] == "REJECT_CONTENT"
  60. assert decision["search_query_effect_status"] == "rule_blocked"
  61. def test_judge_failed_technical_retry_is_config_driven_not_code_special_case(tmp_path):
  62. # Gemini technical failure is config-driven, not a code special-case.
  63. state = _state(tmp_path)
  64. bundle = deepcopy(state["evidence_bundles"][0])
  65. bundle["pattern_match_result"]["judge_status"] = "failed"
  66. decision = decide(state["run_id"], state["policy_run_id"], 1, bundle, state["policy_bundle"])
  67. assert decision["decision_action"] == "TECHNICAL_RETRY_REQUIRED"
  68. assert decision["decision_reason_code"] == "v4_technical_retry_needed"
  69. assert decision["search_query_effect_status"] == "failed"
  70. assert decision["triggered_blocking_rules"] == ["judge_failed"]
  71. assert decision["score"] is None
  72. assert decision["decision_replay_data"]["effect_mapping_id"] == "map_technical_retry_failed_hard_gate"
  73. def test_legacy_low_confidence_field_no_longer_triggers_hard_gate(tmp_path):
  74. state = _state(tmp_path)
  75. bundle = deepcopy(state["evidence_bundles"][0])
  76. bundle["pattern_match_result"]["fit_confidence"] = 0.4
  77. decision = decide(state["run_id"], state["policy_run_id"], 1, bundle, state["policy_bundle"])
  78. assert "low_confidence" not in decision["triggered_blocking_rules"]
  79. assert decision["decision_reason_code"] != "content_low_confidence"
  80. assert decision["decision_reason_code"] != "age_50_plus_weak"
  81. def test_low_confidence_lt_boundary_passes_at_threshold(tmp_path):
  82. # lt 0.6 boundary: exactly 0.6 is NOT low confidence, so the gate does not fire and
  83. # the content falls through to scoring instead of being rule-blocked.
  84. state = _state(tmp_path)
  85. bundle = deepcopy(state["evidence_bundles"][0])
  86. bundle["pattern_match_result"]["fit_confidence"] = 0.6
  87. decision = decide(state["run_id"], state["policy_run_id"], 1, bundle, state["policy_bundle"])
  88. assert "low_confidence" not in decision["triggered_blocking_rules"]
  89. assert decision["decision_reason_code"] != "content_low_confidence"
  90. assert decision["search_query_effect_status"] != "rule_blocked"
  91. def _state(tmp_path):
  92. service = RunService(
  93. runtime_root=tmp_path / "runtime" / "v1",
  94. query_variant_client=FakeQueryVariantClient(),
  95. )
  96. return service.start_run(
  97. RunStartRequest(platform_mode="mock", source=str(REAL_SOURCE_FIXTURE))
  98. )