system-prompts-and-models-o.../dealix/auto_client_acquisition/compliance_os/contactability.py
2026-05-01 14:03:52 +03:00

134 lines
4.4 KiB
Python

"""
Contactability — per-contact "safe to contact?" with reason.
Combines: consent ledger state, frequency caps, quiet hours, blocked
keywords/sectors. Returns a structured ContactabilityStatus that the
orchestrator + Copilot can render in plain Arabic.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from auto_client_acquisition.compliance_os.consent_ledger import (
ConsentRecord,
latest_state,
)
@dataclass
class ContactabilityStatus:
contact_id: str
can_contact: bool
reason_code: str # safe / no_consent / opted_out / freq_cap / quiet_hours / blocked
reason_ar: str # human-readable
has_consent: bool = False
is_opted_out: bool = False
lawful_basis: str | None = None
next_allowed_at: datetime | None = None # if blocked by freq_cap or quiet_hours
def to_dict(self) -> dict[str, Any]:
return {
"contact_id": self.contact_id,
"can_contact": self.can_contact,
"reason_code": self.reason_code,
"reason_ar": self.reason_ar,
"has_consent": self.has_consent,
"is_opted_out": self.is_opted_out,
"lawful_basis": self.lawful_basis,
"next_allowed_at": self.next_allowed_at.isoformat() if self.next_allowed_at else None,
}
# Reason codes → Arabic explanation
_REASON_AR: dict[str, str] = {
"safe": "آمن للتواصل — consent سارٍ ولا opt-out.",
"no_consent": "لا توجد موافقة سارية ولا أساس قانوني واضح للتواصل.",
"opted_out": "المتلقي طلب opt-out سابقاً — لا يمكن التواصل مرة أخرى.",
"freq_cap": "تجاوز عدد الرسائل المسموحة هذا الأسبوع.",
"quiet_hours": "خارج ساعات العمل المسموحة (8ص-9م توقيت الرياض).",
"blocked": "محظور بسبب قاعدة محتوى أو سياسة العميل.",
"expired_consent": "الموافقة انتهت صلاحيتها — جدّدها قبل التواصل.",
}
def check_contactability(
*,
contact_id: str,
consent_records: list[ConsentRecord],
messages_sent_this_week: int = 0,
weekly_cap: int = 2,
current_riyadh_hour: int = 12,
quiet_start_hour: int = 21,
quiet_end_hour: int = 8,
) -> ContactabilityStatus:
"""
Evaluate whether we can contact this person right now.
Order of checks:
1. opt_out → block (permanent)
2. no consent + no legitimate interest → block
3. expired consent → block
4. weekly cap exceeded → freq_cap
5. inside quiet hours → quiet_hours
6. otherwise safe
"""
state = latest_state(consent_records)
if state["is_opted_out"]:
return ContactabilityStatus(
contact_id=contact_id,
can_contact=False,
reason_code="opted_out",
reason_ar=_REASON_AR["opted_out"],
is_opted_out=True,
)
if not state["has_consent"]:
return ContactabilityStatus(
contact_id=contact_id,
can_contact=False,
reason_code="no_consent",
reason_ar=_REASON_AR["no_consent"],
)
# Frequency cap
if messages_sent_this_week >= weekly_cap:
return ContactabilityStatus(
contact_id=contact_id,
can_contact=False,
reason_code="freq_cap",
reason_ar=_REASON_AR["freq_cap"],
has_consent=True,
lawful_basis=state["lawful_basis"],
)
# Quiet hours (Riyadh)
in_quiet = False
if quiet_start_hour < quiet_end_hour:
in_quiet = quiet_start_hour <= current_riyadh_hour < quiet_end_hour
else:
# Wraps midnight (e.g., 21..8)
in_quiet = current_riyadh_hour >= quiet_start_hour or current_riyadh_hour < quiet_end_hour
if in_quiet:
return ContactabilityStatus(
contact_id=contact_id,
can_contact=False,
reason_code="quiet_hours",
reason_ar=_REASON_AR["quiet_hours"],
has_consent=True,
lawful_basis=state["lawful_basis"],
)
return ContactabilityStatus(
contact_id=contact_id,
can_contact=True,
reason_code="safe",
reason_ar=_REASON_AR["safe"],
has_consent=True,
lawful_basis=state["lawful_basis"],
)