File size: 14,604 Bytes
e2d3383
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57d708b
e2d3383
 
 
 
f2b0895
 
 
 
 
 
 
 
 
e2d3383
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f2b0895
 
 
 
 
 
 
 
 
 
e2d3383
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
"""
Claude-powered heat alert explanation generator.

When a heat risk trigger fires, enrolled workers need a plain-language
explanation of WHY, what it means for their payout, and what protective
actions to take. This module generates bilingual (English + Swahili)
explanations using Claude with RAG context, with a template-based
fallback if Claude is unavailable.
"""

from __future__ import annotations

import logging
import os
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any, Dict, Optional

from config import HEAT_THRESHOLDS, PAYOUT_PER_EVENT_USD, ZONE_MAP, UrbanZone
from src.explanation.knowledge_base import (
    INSURANCE_PRODUCT_INFO,
    SWAHILI_TERMS,
    get_emergency_contacts,
    get_protective_actions,
    get_zone_context,
)

log = logging.getLogger(__name__)


# -- Data containers -------------------------------------------------------

@dataclass
class TriggerEvent:
    """A triggered insurance event to be explained."""
    zone_id: str
    trigger_level: str  # critical, warning, watch
    triggered_at: str  # ISO timestamp
    max_temp_c: float = 0.0
    max_wbgt_c: float = 0.0
    consecutive_days: int = 0
    heat_risk_score: float = 0.0
    contributing_factors: list[str] = field(default_factory=list)


@dataclass
class ExplanationResult:
    """Generated explanation for a trigger event."""
    zone_id: str
    trigger_level: str
    english_text: str
    swahili_text: str
    payout_estimate: Dict[str, Any]
    protective_actions: list[str]
    emergency_contacts: Dict[str, str]
    generated_at: str = field(
        default_factory=lambda: datetime.now(timezone.utc).isoformat()
    )
    provider: str = "template"  # claude or template
    zone_name: str = ""
    city: str = ""
    zone_context: str = ""
    tokens_used: int = 0


# -- Claude-based explainer ------------------------------------------------

class TriggerExplainer:
    """Generates bilingual heat alert explanations using Claude with RAG."""

    def __init__(self, api_key: Optional[str] = None, model: str = "claude-haiku-4-5-20251001"):
        self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "")
        self.model = model
        self._client = None

        # Try to load hybrid RAG retriever; falls back to full context injection
        try:
            from src.explanation.rag_provider import HybridRetriever
            self._retriever = HybridRetriever()
            log.info("Hybrid RAG retriever loaded successfully")
        except Exception as exc:
            log.info("RAG retriever not available, using full context injection: %s", exc)
            self._retriever = None

    def _get_client(self):
        if self._client is None:
            try:
                import anthropic
                self._client = anthropic.AsyncAnthropic(api_key=self.api_key)
            except Exception as exc:
                log.warning("Could not init Anthropic client: %s", exc)
        return self._client

    async def explain(self, event, zone=None, basis_risk=None) -> ExplanationResult:
        """Generate a full explanation for a heat trigger event."""
        # Support both TriggerEvent and HeatTriggerEvent dataclass inputs
        zone_id = getattr(event, 'zone_id', '')
        trigger_level = getattr(event, 'trigger_level', 'watch')

        zone_obj = zone or ZONE_MAP.get(zone_id)
        if zone_obj is None:
            raise ValueError(f"Unknown zone: {zone_id}")

        payout = _compute_payout(event, zone_obj)
        actions = get_protective_actions(trigger_level)
        contacts = get_emergency_contacts(zone_obj.city)

        if self.api_key:
            try:
                english, swahili = await self._generate_claude(event, zone_obj, payout)
                return ExplanationResult(
                    zone_id=zone_id,
                    trigger_level=trigger_level,
                    english_text=english,
                    swahili_text=swahili,
                    payout_estimate=payout,
                    protective_actions=actions,
                    emergency_contacts=contacts,
                    provider="claude",
                    zone_name=zone_obj.name,
                    city=zone_obj.city,
                    zone_context=get_zone_context(zone_id),
                )
            except Exception as exc:
                log.warning("Claude explanation failed, using template: %s", exc)

        english = _template_english(event, zone_obj, payout)
        swahili = _template_swahili(event, zone_obj, payout)
        return ExplanationResult(
            zone_id=zone_id,
            trigger_level=trigger_level,
            english_text=english,
            swahili_text=swahili,
            payout_estimate=payout,
            protective_actions=actions,
            emergency_contacts=contacts,
            provider="template",
            zone_name=zone_obj.name,
            city=zone_obj.city,
            zone_context=get_zone_context(zone_id),
        )

    async def _generate_claude(
        self, event, zone: UrbanZone, payout: Dict[str, Any]
    ) -> tuple[str, str]:
        # Use hybrid RAG retrieval if available, otherwise fall back to full injection
        if self._retriever:
            query = (
                f"{event.trigger_level} heat alert {zone.name} {zone.city} "
                f"{zone.settlement_type} outdoor workers"
            )
            context_docs = self._retriever.retrieve(query, zone_id=event.zone_id)
            context = "\n---\n".join(context_docs)
        else:
            context = get_zone_context(event.zone_id)

        system = (
            "You are a heat safety notification system for outdoor workers in East Africa. "
            "Your audience is workers in markets, construction sites, and informal "
            "settlements — many with limited formal education. Write clearly, simply, "
            "and with empathy. Do not use jargon or technical abbreviations. "
            "Explain what happened, what their payout is, and what they should do to "
            "stay safe. Keep the explanation to 4-6 sentences."
        )

        max_temp = getattr(event, 'max_temp_c', 0)
        max_wbgt = getattr(event, 'max_wbgt_c', 0)
        consec = getattr(event, 'consecutive_days', getattr(event, 'consecutive_days_above', 0))
        thresholds = HEAT_THRESHOLDS.get(event.trigger_level, {})

        user = (
            f"A {event.trigger_level.upper()} heat alert has been triggered for "
            f"outdoor workers in {zone.name}, {zone.city}.\n\n"
            f"What happened:\n"
            f"- Maximum temperature: {max_temp:.1f}C "
            f"(threshold: {thresholds.get('temp_c', 'N/A')}C)\n"
            f"- Maximum WBGT (feels-like for workers): {max_wbgt:.1f}C "
            f"(threshold: {thresholds.get('wbgt_c', 'N/A')}C)\n"
            f"- Consecutive days above threshold: {consec} "
            f"(required: {thresholds.get('consecutive_days', 'N/A')})\n"
            f"- Settlement type: {zone.settlement_type}\n"
            f"- Outdoor worker exposure: {zone.outdoor_exposure_pct:.0%}\n"
            f"- Estimated workers affected: {zone.worker_population_est:,}\n\n"
            f"Payout: {payout['currency_symbol']}{payout['amount']} per worker\n\n"
            f"Knowledge base:\n{context}\n\n"
            f"Write a 4-6 sentence explanation for the worker. Tell them what is "
            f"happening with the heat, their payout amount, and the most important "
            f"thing they should do right now to stay safe."
        )

        client = self._get_client()
        if client is None:
            raise RuntimeError("Anthropic client not available")

        msg = await client.messages.create(
            model=self.model,
            max_tokens=500,
            system=system,
            messages=[{"role": "user", "content": user}],
        )
        english = msg.content[0].text.strip()
        swahili = await self._translate_to_swahili(english, zone.name)
        return english, swahili

    async def _translate_to_swahili(self, english_text: str, zone_name: str) -> str:
        system = (
            "You are a professional translator specializing in Swahili (Kiswahili). "
            "Translate the given English heat safety notification to simple, clear "
            "Swahili that a non-technical person can understand. Keep numbers, "
            "currency amounts, and proper nouns unchanged. Return only the translated text."
        )
        user = (
            f"Translate this heat alert notification for workers in {zone_name} "
            f"to Swahili:\n\n{english_text}"
        )

        client = self._get_client()
        if client is None:
            raise RuntimeError("Anthropic client not available")

        msg = await client.messages.create(
            model=self.model,
            max_tokens=600,
            system=system,
            messages=[{"role": "user", "content": user}],
        )
        return msg.content[0].text.strip()


# -- Template-based fallback explainer (same interface) --------------------

class TemplateExplainer:
    """Template-based fallback when Claude is unavailable."""

    async def explain(self, event, zone=None, basis_risk=None) -> ExplanationResult:
        zone_id = getattr(event, 'zone_id', '')
        trigger_level = getattr(event, 'trigger_level', 'watch')
        zone_obj = zone or ZONE_MAP.get(zone_id)
        if zone_obj is None:
            raise ValueError(f"Unknown zone: {zone_id}")

        payout = _compute_payout(event, zone_obj)
        actions = get_protective_actions(trigger_level)
        contacts = get_emergency_contacts(zone_obj.city)

        english = _template_english(event, zone_obj, payout)
        swahili = _template_swahili(event, zone_obj, payout)

        return ExplanationResult(
            zone_id=zone_id,
            trigger_level=trigger_level,
            english_text=english,
            swahili_text=swahili,
            payout_estimate=payout,
            protective_actions=actions,
            emergency_contacts=contacts,
            provider="template",
            zone_name=zone_obj.name,
            city=zone_obj.city,
            zone_context=get_zone_context(zone_id),
        )


# -- Payout computation ---------------------------------------------------

def _compute_payout(event, zone: UrbanZone) -> Dict[str, Any]:
    trigger_level = getattr(event, 'trigger_level', 'watch')
    amount = PAYOUT_PER_EVENT_USD.get(trigger_level, 0)

    return {
        "amount": amount,
        "currency": "USD",
        "currency_symbol": "$",
        "settlement_type": zone.settlement_type,
        "trigger_level": trigger_level,
        "delivery_method": "M-Pesa" if zone.settlement_type in ("informal", "mixed") else "Mobile money",
        "expected_delivery": "Within 48 hours of trigger verification",
        "is_payout": amount > 0,
        "workers_covered": zone.worker_population_est,
        "total_payout": amount * zone.worker_population_est,
    }


# -- Template-based fallback explanations ----------------------------------

_LEVEL_DESCRIPTIONS = {
    "critical": (
        "A CRITICAL heat alert has been triggered",
        "Onyo la HATARI la joto kali limetolewa",
    ),
    "warning": (
        "A WARNING heat alert has been issued",
        "Onyo la joto limetolewa",
    ),
    "watch": (
        "A WATCH heat advisory has been issued",
        "Tahadhari ya joto imetolewa",
    ),
}


def _template_english(event, zone: UrbanZone, payout: Dict[str, Any]) -> str:
    trigger_level = getattr(event, 'trigger_level', 'watch')
    level_en, _ = _LEVEL_DESCRIPTIONS.get(trigger_level, ("A heat alert has been issued", ""))

    max_temp = getattr(event, 'max_temp_c', 0)
    max_wbgt = getattr(event, 'max_wbgt_c', 0)
    consec = getattr(event, 'consecutive_days', getattr(event, 'consecutive_days_above', 0))

    lines = [f"{level_en} for outdoor workers in {zone.name}, {zone.city}."]

    thresholds = HEAT_THRESHOLDS.get(trigger_level, {})
    reasons: list[str] = []
    if max_temp >= thresholds.get("temp_c", 999):
        reasons.append(
            f"temperatures reached {max_temp:.0f}C, above the "
            f"{thresholds['temp_c']}C safety threshold"
        )
    if max_wbgt >= thresholds.get("wbgt_c", 999):
        reasons.append(
            f"the heat-humidity index (WBGT) reached {max_wbgt:.0f}C, "
            f"above the {thresholds['wbgt_c']}C danger level"
        )
    if consec >= thresholds.get("consecutive_days", 999):
        reasons.append(
            f"these dangerous conditions lasted {consec} consecutive days"
        )

    if reasons:
        lines.append("This was triggered because " + " and ".join(reasons) + ".")
    else:
        lines.append("Heat conditions have exceeded the safety threshold for your area.")

    if payout["is_payout"]:
        lines.append(
            f"Your payout is {payout['currency_symbol']}{payout['amount']} per worker, "
            f"which will be sent via {payout['delivery_method']} within 48 hours."
        )

    actions = get_protective_actions(trigger_level)
    if actions:
        lines.append(f"Most important: {actions[0]}")

    return " ".join(lines)


def _template_swahili(event, zone: UrbanZone, payout: Dict[str, Any]) -> str:
    trigger_level = getattr(event, 'trigger_level', 'watch')
    _, level_sw = _LEVEL_DESCRIPTIONS.get(trigger_level, ("", "Onyo la joto limetolewa"))

    max_temp = getattr(event, 'max_temp_c', 0)
    consec = getattr(event, 'consecutive_days', getattr(event, 'consecutive_days_above', 0))

    lines = [f"{level_sw} kwa wafanyakazi wa nje katika {zone.name}, {zone.city}."]

    lines.append(
        f"Joto limefika {max_temp:.0f}C kwa siku {consec} mfululizo."
    )

    if payout["is_payout"]:
        lines.append(
            f"Malipo yako ni {payout['currency_symbol']}{payout['amount']} kwa kila mfanyakazi. "
            f"Utapokea kupitia {payout['delivery_method']} ndani ya masaa 48."
        )

    if trigger_level == "critical":
        lines.append(
            "Acha kazi za nje sasa hivi. Nenda kivulini au ndani ya nyumba. "
            "Kunywa maji mengi."
        )
    elif trigger_level == "warning":
        lines.append(
            "Punguza kazi za nje. Fanya kazi nzito asubuhi mapema au jioni. "
            "Pumzika kivulini kila saa."
        )
    else:
        lines.append(
            "Kuwa makini. Kunywa maji zaidi. Panga kazi nzito kwa masaa ya baridi."
        )

    return " ".join(lines)