179 lines
9.6 KiB
Python
179 lines
9.6 KiB
Python
import unittest
|
|
|
|
from src.services.alerts import AlertQualitySummaryService
|
|
|
|
|
|
class _DecisionRepository:
|
|
def fetch_recent_decision_alerts(self, *, instrument_id: int, limit: int = 50):
|
|
return [
|
|
{"id": 1, "alert_type": "candidate_actionable", "payload": {"session_code": "LONDON"}},
|
|
{"id": 2, "alert_type": "runtime_degraded", "payload": {"session_code": "NY_AM"}},
|
|
][:limit]
|
|
|
|
|
|
class _DeliveryRepository:
|
|
def fetch_recent_deliveries_for_alerts(self, alert_ids, limit: int = 200):
|
|
return [
|
|
{"alert_id": 1, "status": "delivered", "channel": "webhook", "attempted_ts": "2026-05-11T10:00:00+00:00", "payload": {"dispatch_mode": "failed_retry", "result": {"attempt_count": 2}, "media_evidence": {"status": "media_linked"}}},
|
|
{"alert_id": 2, "status": "failed", "channel": "webhook", "attempted_ts": "2026-05-11T09:59:00+00:00", "payload": {"media_evidence": {"status": "media_unavailable"}}},
|
|
{"alert_id": 1, "status": "skipped", "channel": "workbench", "attempted_ts": "2026-05-11T09:58:00+00:00", "payload": {"reason": "retry_exhausted", "media_evidence": {"status": "media_attached"}}},
|
|
{"alert_id": 2, "status": "skipped", "channel": "webhook", "attempted_ts": "2026-05-11T09:57:00+00:00", "payload": {"reason": "suppressed_noise_policy", "delivery_policy": {"action": "suppressed_noise_policy"}, "media_evidence": {"status": "media_linked"}}},
|
|
{"alert_id": 1, "status": "skipped", "channel": "workbench", "attempted_ts": "2026-05-11T09:56:00+00:00", "payload": {"reason": "workbench_only", "delivery_policy": {"action": "workbench_only"}, "media_evidence": {"status": "media_attached"}}},
|
|
][:limit]
|
|
|
|
|
|
class _FeedbackRepository:
|
|
def fetch_feedback_for_alert_ids(self, alert_ids, limit: int = 200):
|
|
return [
|
|
{"alert_id": 1, "label": "useful", "created_ts": "2026-05-11T10:05:00+00:00"},
|
|
{"alert_id": 2, "label": "false_positive", "created_ts": "2026-05-11T10:04:00+00:00"},
|
|
][:limit]
|
|
|
|
def fetch_feedback_for_visual_review_ids(self, visual_review_ids, limit: int = 200):
|
|
return [
|
|
{"visual_review_id": 31, "label": "visual_helpful", "created_ts": "2026-05-11T10:03:00+00:00"},
|
|
{"visual_review_id": 32, "label": "visual_misleading", "created_ts": "2026-05-11T10:02:00+00:00"},
|
|
{"visual_review_id": 32, "label": "actionable", "created_ts": "2026-05-11T10:01:00+00:00"},
|
|
][:limit]
|
|
|
|
|
|
class _VisualRepository:
|
|
def fetch_recent_reviews_for_instrument(self, instrument_id: int, limit: int = 50):
|
|
return [
|
|
{"id": 31, "visual_state": "confirm", "trigger_type": "candidate_actionable_watch", "reviewed_ts": "2026-05-11T10:00:00+00:00"},
|
|
{"id": 32, "visual_state": "mismatch", "trigger_type": "market_anomaly_review", "reviewed_ts": "2026-05-11T09:59:00+00:00"},
|
|
][:limit]
|
|
|
|
|
|
class _SilentWindowService:
|
|
def __init__(self, active: bool = False) -> None:
|
|
self.active = active
|
|
|
|
def build_status(self):
|
|
return {
|
|
"active": self.active,
|
|
"enabled": True,
|
|
"window_label": "00:00-06:00",
|
|
"timezone": "UTC",
|
|
"next_allowed_ts": "2026-05-12T06:00:00+00:00" if self.active else None,
|
|
}
|
|
|
|
|
|
class AlertQualitySummaryServiceTests(unittest.TestCase):
|
|
def test_build_summary_aggregates_delivery_and_feedback_rates(self) -> None:
|
|
service = AlertQualitySummaryService(
|
|
decision_repository=_DecisionRepository(),
|
|
delivery_repository=_DeliveryRepository(),
|
|
feedback_repository=_FeedbackRepository(),
|
|
visual_repository=_VisualRepository(),
|
|
silent_window_service=_SilentWindowService(active=True),
|
|
)
|
|
|
|
summary = service.build_summary(instrument_id=1, alert_limit=10, visual_review_limit=10)
|
|
|
|
self.assertEqual(summary["recent_alert_count"], 2)
|
|
self.assertEqual(summary["delivery_summary"]["delivered_count"], 1)
|
|
self.assertEqual(summary["delivery_summary"]["failed_count"], 1)
|
|
self.assertEqual(summary["delivery_summary"]["skipped_count"], 3)
|
|
self.assertEqual(summary["delivery_summary"]["retried_delivery_count"], 1)
|
|
self.assertEqual(summary["delivery_summary"]["retry_exhausted_skip_count"], 1)
|
|
self.assertEqual(summary["delivery_summary"]["noise_policy_skip_count"], 1)
|
|
self.assertEqual(summary["delivery_summary"]["workbench_only_skip_count"], 1)
|
|
self.assertEqual(summary["delivery_summary"]["transport_retried_delivery_count"], 1)
|
|
self.assertEqual(summary["delivery_summary"]["media_attached_count"], 2)
|
|
self.assertEqual(summary["delivery_summary"]["media_linked_count"], 2)
|
|
self.assertEqual(summary["delivery_summary"]["media_unavailable_count"], 1)
|
|
self.assertEqual(summary["delivery_summary"]["channel_status_counts"]["webhook"]["delivered"], 1)
|
|
self.assertEqual(summary["delivery_summary"]["channel_status_counts"]["webhook"]["failed"], 1)
|
|
self.assertEqual(summary["delivery_summary"]["channel_status_counts"]["workbench"]["skipped"], 2)
|
|
self.assertEqual(summary["delivery_summary"]["channel_disabled_count"], 0)
|
|
self.assertTrue(summary["silent_window"]["active"])
|
|
self.assertEqual(summary["delivery_summary"]["success_rate"], 0.5)
|
|
self.assertEqual(summary["alert_feedback_summary"]["positive_count"], 1)
|
|
self.assertEqual(summary["alert_feedback_summary"]["negative_count"], 1)
|
|
self.assertEqual(summary["alert_feedback_summary"]["positive_rate"], 0.5)
|
|
self.assertEqual(summary["visual_feedback_summary"]["positive_count"], 2)
|
|
self.assertEqual(summary["visual_feedback_summary"]["negative_count"], 1)
|
|
self.assertAlmostEqual(summary["visual_feedback_summary"]["positive_rate"], 2 / 3)
|
|
self.assertEqual(summary["visual_review_summary"]["state_counts"]["confirm"], 1)
|
|
self.assertEqual(summary["visual_review_summary"]["state_counts"]["mismatch"], 1)
|
|
self.assertEqual(summary["visual_review_summary"]["trigger_type_counts"]["candidate_actionable_watch"], 1)
|
|
self.assertEqual(summary["visual_review_summary"]["trigger_type_counts"]["market_anomaly_review"], 1)
|
|
self.assertIn("delivery_failures_present", summary["quality_flags"])
|
|
self.assertIn("alert_silent_window_active", summary["quality_flags"])
|
|
self.assertIn("delivery_retry_exhausted_present", summary["quality_flags"])
|
|
self.assertIn("delivery_noise_policy_suppression_present", summary["quality_flags"])
|
|
self.assertIn("visual_negative_feedback_present", summary["quality_flags"])
|
|
self.assertIn("visual_mismatch_present", summary["quality_flags"])
|
|
|
|
def test_build_summary_reports_missing_feedback_samples(self) -> None:
|
|
class _EmptyDeliveryRepository:
|
|
def fetch_recent_deliveries_for_alerts(self, alert_ids, limit: int = 200):
|
|
return []
|
|
|
|
class _EmptyFeedbackRepository:
|
|
def fetch_feedback_for_alert_ids(self, alert_ids, limit: int = 200):
|
|
return []
|
|
|
|
def fetch_feedback_for_visual_review_ids(self, visual_review_ids, limit: int = 200):
|
|
return []
|
|
|
|
class _EmptyVisualRepository:
|
|
def fetch_recent_reviews_for_instrument(self, instrument_id: int, limit: int = 50):
|
|
return []
|
|
|
|
service = AlertQualitySummaryService(
|
|
decision_repository=_DecisionRepository(),
|
|
delivery_repository=_EmptyDeliveryRepository(),
|
|
feedback_repository=_EmptyFeedbackRepository(),
|
|
visual_repository=_EmptyVisualRepository(),
|
|
silent_window_service=_SilentWindowService(active=False),
|
|
)
|
|
|
|
summary = service.build_summary(instrument_id=1)
|
|
|
|
self.assertIsNone(summary["delivery_summary"]["success_rate"])
|
|
self.assertEqual(summary["alert_feedback_summary"]["count"], 0)
|
|
self.assertEqual(summary["visual_feedback_summary"]["count"], 0)
|
|
self.assertIn("feedback_sample_missing", summary["quality_flags"])
|
|
|
|
def test_build_summary_degrades_when_optional_tables_are_unavailable(self) -> None:
|
|
class _BrokenDeliveryRepository:
|
|
def fetch_recent_deliveries_for_alerts(self, alert_ids, limit: int = 200):
|
|
raise RuntimeError("should not be used for sqlalchemy path")
|
|
|
|
class _BrokenFeedbackRepository:
|
|
def fetch_feedback_for_alert_ids(self, alert_ids, limit: int = 200):
|
|
from sqlalchemy.exc import ProgrammingError
|
|
|
|
raise ProgrammingError("stmt", {}, Exception())
|
|
|
|
def fetch_feedback_for_visual_review_ids(self, visual_review_ids, limit: int = 200):
|
|
from sqlalchemy.exc import ProgrammingError
|
|
|
|
raise ProgrammingError("stmt", {}, Exception())
|
|
|
|
class _BrokenVisualRepository:
|
|
def fetch_recent_reviews_for_instrument(self, instrument_id: int, limit: int = 50):
|
|
from sqlalchemy.exc import ProgrammingError
|
|
|
|
raise ProgrammingError("stmt", {}, Exception())
|
|
|
|
service = AlertQualitySummaryService(
|
|
decision_repository=_DecisionRepository(),
|
|
delivery_repository=_DeliveryRepository(),
|
|
feedback_repository=_BrokenFeedbackRepository(),
|
|
visual_repository=_BrokenVisualRepository(),
|
|
silent_window_service=_SilentWindowService(active=False),
|
|
)
|
|
|
|
summary = service.build_summary(instrument_id=1)
|
|
|
|
self.assertIn("alert_feedback_unavailable", summary["warnings"])
|
|
self.assertIn("visual_reviews_unavailable", summary["warnings"])
|
|
self.assertIn("visual_feedback_unavailable", summary["warnings"])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|