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

350 lines
13 KiB
Python

"""
Projections — read models computed by replaying the event stream.
Each projection is a deterministic function of (event_stream, filter).
This means: nuke the projection storage, replay events, get identical state.
That property is the foundation of the audit trail and disaster recovery.
Six projections cover the dashboard's needs:
- AccountTimeline — chronological story of one account/company
- DealHealthProjection — current state + risk signals for one deal
- CampaignPerformanceProjection — sent/replied/won per campaign
- AgentActionLedger — every AI agent action + approvals
- ComplianceAuditProjection — for SDAIA / DPO inspection
- CustomerROIProjection — what Dealix delivered this period
"""
from __future__ import annotations
from collections import defaultdict
from collections.abc import Iterable
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any
from auto_client_acquisition.revenue_memory.events import RevenueEvent
# ── 1. Account Timeline ──────────────────────────────────────────
@dataclass
class TimelineEntry:
occurred_at: datetime
event_type: str
headline: str
payload: dict[str, Any]
@dataclass
class AccountTimeline:
customer_id: str
account_id: str
entries: list[TimelineEntry] = field(default_factory=list)
first_seen: datetime | None = None
last_activity: datetime | None = None
n_messages_sent: int = 0
n_replies: int = 0
n_meetings: int = 0
n_signals: int = 0
def to_dict(self) -> dict[str, Any]:
return {
"customer_id": self.customer_id,
"account_id": self.account_id,
"first_seen": self.first_seen.isoformat() if self.first_seen else None,
"last_activity": self.last_activity.isoformat() if self.last_activity else None,
"metrics": {
"messages_sent": self.n_messages_sent,
"replies": self.n_replies,
"meetings": self.n_meetings,
"signals": self.n_signals,
},
"entries": [
{
"at": e.occurred_at.isoformat(),
"type": e.event_type,
"headline": e.headline,
"payload": e.payload,
}
for e in self.entries
],
}
_TYPE_HEADLINES_AR: dict[str, str] = {
"lead.created": "أُنشئ lead جديد",
"lead.qualified": "تمت تأهيل الـ lead",
"company.enriched": "اكتمل enrichment الشركة",
"signal.detected": "اكتُشفت إشارة شراء",
"message.drafted": "تم إعداد رسالة",
"message.approved": "وافق المستخدم على الرسالة",
"message.sent": "أُرسلت الرسالة",
"reply.received": "وصل رد",
"reply.classified": "تم تصنيف الرد",
"meeting.booked": "تم حجز اجتماع",
"meeting.held": "انعقد الاجتماع",
"deal.created": "أُنشئت صفقة",
"deal.stage_changed": "تغيرت مرحلة الصفقة",
"deal.won": "🏆 صفقة مكسوبة",
"deal.lost": "صفقة ضائعة",
"deal.stalled": "صفقة جامدة",
"compliance.opt_out_received": "وصل طلب opt-out",
"compliance.blocked": "حُظرت رسالة لأسباب امتثال",
}
def build_account_timeline(
*,
customer_id: str,
account_id: str,
events: Iterable[RevenueEvent],
) -> AccountTimeline:
"""Replay events for one account into a chronological timeline."""
timeline = AccountTimeline(customer_id=customer_id, account_id=account_id)
sorted_events = sorted(events, key=lambda e: e.occurred_at)
for e in sorted_events:
if e.subject_type not in ("account", "company") or e.subject_id != account_id:
continue
if timeline.first_seen is None:
timeline.first_seen = e.occurred_at
timeline.last_activity = e.occurred_at
if e.event_type == "message.sent":
timeline.n_messages_sent += 1
elif e.event_type in ("reply.received", "reply.classified"):
if e.event_type == "reply.received":
timeline.n_replies += 1
elif e.event_type in ("meeting.booked", "meeting.held"):
if e.event_type == "meeting.booked":
timeline.n_meetings += 1
elif e.event_type == "signal.detected":
timeline.n_signals += 1
timeline.entries.append(
TimelineEntry(
occurred_at=e.occurred_at,
event_type=e.event_type,
headline=_TYPE_HEADLINES_AR.get(e.event_type, e.event_type),
payload=e.payload,
)
)
return timeline
# ── 2. Deal Health Projection ────────────────────────────────────
@dataclass
class DealHealthProjection:
customer_id: str
deal_id: str
current_stage: str = "unknown"
value_sar: float = 0.0
days_in_current_stage: int = 0
last_activity_at: datetime | None = None
risk_flags: list[str] = field(default_factory=list)
health_score: float = 50.0
expected_close: datetime | None = None
stage_history: list[tuple[datetime, str]] = field(default_factory=list)
def build_deal_health(
*, customer_id: str, deal_id: str, events: Iterable[RevenueEvent], now: datetime
) -> DealHealthProjection:
"""Compute current deal health from its event stream."""
proj = DealHealthProjection(customer_id=customer_id, deal_id=deal_id)
last_stage_at = None
for e in sorted(events, key=lambda x: x.occurred_at):
if e.subject_type != "deal" or e.subject_id != deal_id:
continue
proj.last_activity_at = e.occurred_at
if e.event_type == "deal.created":
proj.value_sar = float(e.payload.get("value_sar", 0))
proj.current_stage = e.payload.get("stage", "open")
last_stage_at = e.occurred_at
proj.stage_history.append((e.occurred_at, proj.current_stage))
elif e.event_type == "deal.stage_changed":
new_stage = e.payload.get("to_stage", proj.current_stage)
proj.current_stage = new_stage
last_stage_at = e.occurred_at
proj.stage_history.append((e.occurred_at, new_stage))
elif e.event_type == "deal.won":
proj.current_stage = "won"
proj.health_score = 100
proj.risk_flags = []
elif e.event_type == "deal.lost":
proj.current_stage = "lost"
proj.health_score = 0
elif e.event_type == "deal.stalled":
proj.risk_flags.append("stalled")
proj.health_score = max(0, proj.health_score - 30)
if last_stage_at and proj.current_stage not in ("won", "lost"):
proj.days_in_current_stage = max(0, (now - last_stage_at).days)
if proj.days_in_current_stage > 21:
proj.risk_flags.append(f"in_stage_{proj.days_in_current_stage}d")
proj.health_score = max(0, proj.health_score - 20)
return proj
# ── 3. Campaign Performance Projection ──────────────────────────
@dataclass
class CampaignPerformanceProjection:
customer_id: str
campaign_id: str
sent: int = 0
replied: int = 0
meetings_booked: int = 0
deals_won: int = 0
revenue_won_sar: float = 0.0
blocked_compliance: int = 0
open_rate: float = 0.0
reply_rate: float = 0.0
win_rate: float = 0.0
def build_campaign_performance(
*, customer_id: str, campaign_id: str, events: Iterable[RevenueEvent]
) -> CampaignPerformanceProjection:
proj = CampaignPerformanceProjection(customer_id=customer_id, campaign_id=campaign_id)
n_opened = 0
for e in events:
if e.payload.get("campaign_id") != campaign_id:
continue
if e.event_type == "message.sent":
proj.sent += 1
elif e.event_type == "message.opened":
n_opened += 1
elif e.event_type == "reply.received":
proj.replied += 1
elif e.event_type == "meeting.booked":
proj.meetings_booked += 1
elif e.event_type == "deal.won":
proj.deals_won += 1
proj.revenue_won_sar += float(e.payload.get("value_sar", 0))
elif e.event_type == "compliance.blocked":
proj.blocked_compliance += 1
if proj.sent:
proj.open_rate = round(n_opened / proj.sent, 4)
proj.reply_rate = round(proj.replied / proj.sent, 4)
if proj.meetings_booked:
proj.win_rate = round(proj.deals_won / proj.meetings_booked, 4)
return proj
# ── 4. Agent Action Ledger ──────────────────────────────────────
@dataclass
class AgentAction:
occurred_at: datetime
event_type: str # requested / approved / rejected / executed / failed
agent_id: str
task_id: str
actor: str
payload: dict[str, Any]
@dataclass
class AgentActionLedger:
customer_id: str
actions: list[AgentAction] = field(default_factory=list)
by_agent: dict[str, int] = field(default_factory=dict)
by_status: dict[str, int] = field(default_factory=dict)
requires_review: int = 0
def build_agent_ledger(
*, customer_id: str, events: Iterable[RevenueEvent]
) -> AgentActionLedger:
ledger = AgentActionLedger(customer_id=customer_id)
for e in events:
if not e.event_type.startswith("agent."):
continue
agent_id = e.payload.get("agent_id", "unknown")
action = AgentAction(
occurred_at=e.occurred_at,
event_type=e.event_type,
agent_id=agent_id,
task_id=e.payload.get("task_id", ""),
actor=e.actor,
payload=e.payload,
)
ledger.actions.append(action)
ledger.by_agent[agent_id] = ledger.by_agent.get(agent_id, 0) + 1
status = e.event_type.split(".", 1)[1]
ledger.by_status[status] = ledger.by_status.get(status, 0) + 1
if e.event_type == "agent.action_requested" and e.payload.get("requires_approval"):
ledger.requires_review += 1
return ledger
# ── 5. Compliance Audit Projection ──────────────────────────────
@dataclass
class ComplianceAuditProjection:
customer_id: str
consent_recorded: int = 0
opt_outs: int = 0
blocked_messages: int = 0
dsr_received: int = 0
dsr_completed: int = 0
last_block_reason: str | None = None
def build_compliance_audit(
*, customer_id: str, events: Iterable[RevenueEvent]
) -> ComplianceAuditProjection:
proj = ComplianceAuditProjection(customer_id=customer_id)
for e in events:
if e.event_type == "compliance.consent_recorded":
proj.consent_recorded += 1
elif e.event_type == "compliance.opt_out_received":
proj.opt_outs += 1
elif e.event_type == "compliance.blocked":
proj.blocked_messages += 1
proj.last_block_reason = e.payload.get("reason")
elif e.event_type == "compliance.dsr_received":
proj.dsr_received += 1
elif e.event_type == "compliance.dsr_completed":
proj.dsr_completed += 1
return proj
# ── 6. Customer ROI Projection ──────────────────────────────────
@dataclass
class CustomerROIProjection:
customer_id: str
period_start: datetime | None
period_end: datetime | None
n_leads: int = 0
n_meetings: int = 0
n_proposals: int = 0
n_deals_won: int = 0
revenue_won_sar: float = 0.0
pipeline_added_sar: float = 0.0
def build_customer_roi(
*,
customer_id: str,
events: Iterable[RevenueEvent],
period_start: datetime | None = None,
period_end: datetime | None = None,
) -> CustomerROIProjection:
proj = CustomerROIProjection(
customer_id=customer_id,
period_start=period_start,
period_end=period_end,
)
for e in events:
if period_start and e.occurred_at < period_start:
continue
if period_end and e.occurred_at > period_end:
continue
if e.event_type == "lead.created":
proj.n_leads += 1
elif e.event_type == "meeting.booked":
proj.n_meetings += 1
elif e.event_type == "deal.proposal_sent":
proj.n_proposals += 1
elif e.event_type == "deal.created":
proj.pipeline_added_sar += float(e.payload.get("value_sar", 0))
elif e.event_type == "deal.won":
proj.n_deals_won += 1
proj.revenue_won_sar += float(e.payload.get("value_sar", 0))
return proj