File size: 3,770 Bytes
37ee895
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
from __future__ import annotations

import threading
import time
from dataclasses import dataclass
from typing import Any

from bot.coaching_churn_risk import build_coaching_signature, build_viewer_churn_payload
from bot.hud_runtime import hud_runtime
from bot.observability_helpers import utc_iso

_HUD_EMIT_BANDS = {"high", "critical"}


@dataclass
class _CoachingEmissionState:
    last_signature: str = ""
    last_emitted_at: float = 0.0
    suppressed_total: int = 0


class CoachingRuntime:
    def __init__(self, *, cooldown_seconds: float = 120.0) -> None:
        self._lock = threading.Lock()
        self._cooldown_seconds = max(10.0, float(cooldown_seconds))
        self._channel_state: dict[str, _CoachingEmissionState] = {}

    @staticmethod
    def _normalize_channel_id(channel_id: str | None) -> str:
        normalized = str(channel_id or "").strip().lower()
        return normalized or "default"

    @staticmethod
    def _build_hud_message(channel_id: str, payload: dict[str, Any]) -> str:
        risk_band = str(payload.get("risk_band") or "low").strip().lower() or "low"
        risk_score = int(payload.get("risk_score") or 0)
        alert = payload.get("primary_alert") or {}
        title = str(alert.get("title") or "Sinal de churn")
        tactic = str(alert.get("tactic") or "Aplique CTA curto e valide resposta do chat.")
        text = f"[COACHING {risk_band.upper()} {risk_score}/100] #{channel_id}: {title}. Acao: {tactic}"
        return text[:300]

    def reset(self) -> None:
        with self._lock:
            self._channel_state.clear()

    def evaluate_and_emit(
        self,
        snapshot: dict[str, Any] | None,
        *,
        channel_id: str | None = None,
        now: float | None = None,
        emit_hud: bool = True,
    ) -> dict[str, Any]:
        safe_channel_id = self._normalize_channel_id(channel_id)
        current_now = float(now) if isinstance(now, int | float) else time.time()
        payload = build_viewer_churn_payload(snapshot)
        signature = build_coaching_signature(payload)

        emitted = False
        suppressed = False
        should_emit = (
            emit_hud
            and bool(payload.get("has_alerts"))
            and str(payload.get("risk_band") or "").strip().lower() in _HUD_EMIT_BANDS
        )
        message_text = ""

        with self._lock:
            state = self._channel_state.setdefault(safe_channel_id, _CoachingEmissionState())
            if should_emit:
                repeated_signature = state.last_signature == signature
                cooldown_active = (current_now - state.last_emitted_at) < self._cooldown_seconds
                if repeated_signature and cooldown_active:
                    suppressed = True
                    state.suppressed_total += 1
                else:
                    emitted = True
                    state.last_signature = signature
                    state.last_emitted_at = current_now
                    message_text = self._build_hud_message(safe_channel_id, payload)

            hud_payload = {
                "emitted": emitted,
                "suppressed": suppressed,
                "cooldown_seconds": int(self._cooldown_seconds),
                "last_emitted_at": utc_iso(state.last_emitted_at) if state.last_emitted_at else "",
                "suppressed_total": int(state.suppressed_total),
                "signature": state.last_signature,
            }

        if emitted and message_text:
            hud_runtime.push_message(message_text, source="coaching")

        return {
            **payload,
            "channel_id": safe_channel_id,
            "hud": hud_payload,
        }


coaching_runtime = CoachingRuntime()

__all__ = ["CoachingRuntime", "coaching_runtime"]