Add AI trigger explanations behind EXPLANATION_MODE flag
Browse filesWire TriggerExplainer into step_explain so explanation generation
can be switched from the default free template path to a Claude-
Haiku-4.5 path via EXPLANATION_MODE=ai. Default stays "template"
so nothing changes at runtime without an opt-in.
- config.py: read EXPLANATION_MODE env var (default "template")
- src/explanation/explainer.py: switch TriggerExplainer default
model from Sonnet to Haiku 4.5 (advisory rewriting is a
template-fill task, not reasoning)
- src/pipeline.py: dispatch Template vs Trigger in _step_explain
based on the flag; report mode and cost in StepResult details
- frontend/src/pages/Pipeline.tsx: add ScalingCostPanel showing
three tiers (current/pilot/state-wide) on the Architecture tab
The design point the panel makes: LLM cost is decoupled from
worker population because explanations fire at trigger events
(a handful per week), not per worker. Pilot at 106K workers
costs the same ~$0.05/wk as the demo; AI explanations on add
less than a dollar even at 10x coverage.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- config.py +7 -0
- frontend/src/pages/Pipeline.tsx +70 -1
- src/explanation/explainer.py +1 -1
- src/pipeline.py +16 -4
|
@@ -6,6 +6,7 @@ Zones are real neighborhoods with outdoor worker populations
|
|
| 6 |
vulnerable to heat stress.
|
| 7 |
"""
|
| 8 |
|
|
|
|
| 9 |
from dataclasses import dataclass, field
|
| 10 |
|
| 11 |
REGION_NAME = "East Africa"
|
|
@@ -14,6 +15,12 @@ LOCALE = "en-KE"
|
|
| 14 |
CURRENCY = "USD"
|
| 15 |
CURRENCY_SYMBOL = "$"
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
# Data source configuration
|
| 18 |
NASA_POWER_URL = "https://power.larc.nasa.gov/api/temporal/daily/point"
|
| 19 |
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
|
|
|
| 6 |
vulnerable to heat stress.
|
| 7 |
"""
|
| 8 |
|
| 9 |
+
import os
|
| 10 |
from dataclasses import dataclass, field
|
| 11 |
|
| 12 |
REGION_NAME = "East Africa"
|
|
|
|
| 15 |
CURRENCY = "USD"
|
| 16 |
CURRENCY_SYMBOL = "$"
|
| 17 |
|
| 18 |
+
# Explanation mode for step 5 (EXPLAIN).
|
| 19 |
+
# "template" — free, instant, deterministic templates (default)
|
| 20 |
+
# "ai" — Claude Haiku 4.5 generates bilingual explanations per trigger event
|
| 21 |
+
# Scales with trigger events fired, not worker population.
|
| 22 |
+
EXPLANATION_MODE = os.environ.get("EXPLANATION_MODE", "template").strip().lower()
|
| 23 |
+
|
| 24 |
# Data source configuration
|
| 25 |
NASA_POWER_URL = "https://power.larc.nasa.gov/api/temporal/daily/point"
|
| 26 |
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
|
@@ -132,7 +132,75 @@ function ArchitectureDiagram() {
|
|
| 132 |
)
|
| 133 |
}
|
| 134 |
|
| 135 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
|
| 137 |
// ---------------------------------------------------------------------------
|
| 138 |
// Main Component
|
|
@@ -289,6 +357,7 @@ export default function Pipeline() {
|
|
| 289 |
{activeTab === 'architecture' && (
|
| 290 |
<div className="animate-tab-enter">
|
| 291 |
<ArchitectureDiagram />
|
|
|
|
| 292 |
</div>
|
| 293 |
)}
|
| 294 |
|
|
|
|
| 132 |
)
|
| 133 |
}
|
| 134 |
|
| 135 |
+
// ---------------------------------------------------------------------------
|
| 136 |
+
// Scaling Cost Panel
|
| 137 |
+
// ---------------------------------------------------------------------------
|
| 138 |
+
// Shows how LLM cost scales (or doesn't) with worker population. The design
|
| 139 |
+
// point: per-user cost is decoupled from LLM cost because all AI work happens
|
| 140 |
+
// at the zone and trigger-event level, not the worker level.
|
| 141 |
+
|
| 142 |
+
function ScalingCostPanel() {
|
| 143 |
+
const TIERS = [
|
| 144 |
+
{
|
| 145 |
+
label: 'Current (live)',
|
| 146 |
+
scale: '15 zones · template explanations',
|
| 147 |
+
cost: '~$0.05 / week',
|
| 148 |
+
note: 'Rule-based alert messages. Zero LLM spend on explanations.',
|
| 149 |
+
color: '#2a9d8f',
|
| 150 |
+
},
|
| 151 |
+
{
|
| 152 |
+
label: 'Pilot (106K workers)',
|
| 153 |
+
scale: '15 zones · AI explanations on',
|
| 154 |
+
cost: '~$0.10 / week',
|
| 155 |
+
note: 'Claude Haiku writes bilingual alerts for every trigger event. Cost scales with events fired, not workers.',
|
| 156 |
+
color: '#1565C0',
|
| 157 |
+
},
|
| 158 |
+
{
|
| 159 |
+
label: 'State-wide (10× coverage)',
|
| 160 |
+
scale: '150 zones · AI explanations on',
|
| 161 |
+
cost: '< $1 / week',
|
| 162 |
+
note: 'Because alerts are per trigger event (not per worker), population scale barely moves the cost line.',
|
| 163 |
+
color: '#d4a019',
|
| 164 |
+
},
|
| 165 |
+
]
|
| 166 |
+
|
| 167 |
+
return (
|
| 168 |
+
<div style={{ marginTop: '32px', paddingLeft: '20px' }}>
|
| 169 |
+
<div style={{
|
| 170 |
+
fontSize: '0.72rem', fontWeight: 600, color: '#888',
|
| 171 |
+
textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '12px',
|
| 172 |
+
}}>
|
| 173 |
+
What it costs to run at scale
|
| 174 |
+
</div>
|
| 175 |
+
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(260px, 1fr))', gap: '12px' }}>
|
| 176 |
+
{TIERS.map(tier => (
|
| 177 |
+
<div key={tier.label} style={{
|
| 178 |
+
background: '#fff', border: '1px solid #e0dcd5', borderRadius: '8px',
|
| 179 |
+
padding: '16px 18px', borderLeft: `3px solid ${tier.color}`,
|
| 180 |
+
}}>
|
| 181 |
+
<div style={{ fontSize: '0.72rem', fontWeight: 600, color: tier.color, textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '6px' }}>
|
| 182 |
+
{tier.label}
|
| 183 |
+
</div>
|
| 184 |
+
<div style={{ fontSize: '1.35rem', fontWeight: 700, color: '#1a1a1a', fontFamily: 'Source Serif 4, serif', marginBottom: '4px' }}>
|
| 185 |
+
{tier.cost}
|
| 186 |
+
</div>
|
| 187 |
+
<div style={{ fontSize: '0.78rem', color: '#666', marginBottom: '8px' }}>
|
| 188 |
+
{tier.scale}
|
| 189 |
+
</div>
|
| 190 |
+
<div style={{ fontSize: '0.72rem', color: '#888', lineHeight: 1.5 }}>
|
| 191 |
+
{tier.note}
|
| 192 |
+
</div>
|
| 193 |
+
</div>
|
| 194 |
+
))}
|
| 195 |
+
</div>
|
| 196 |
+
<p style={{ fontSize: '0.72rem', color: '#888', marginTop: '12px', fontStyle: 'italic' }}>
|
| 197 |
+
AI explanations are off by default. Set{' '}
|
| 198 |
+
<code style={{ background: '#f0ede8', padding: '1px 6px', borderRadius: '3px', fontSize: '0.7rem' }}>EXPLANATION_MODE=ai</code>
|
| 199 |
+
{' '}to enable. The capability is wired end-to-end either way.
|
| 200 |
+
</p>
|
| 201 |
+
</div>
|
| 202 |
+
)
|
| 203 |
+
}
|
| 204 |
|
| 205 |
// ---------------------------------------------------------------------------
|
| 206 |
// Main Component
|
|
|
|
| 357 |
{activeTab === 'architecture' && (
|
| 358 |
<div className="animate-tab-enter">
|
| 359 |
<ArchitectureDiagram />
|
| 360 |
+
<ScalingCostPanel />
|
| 361 |
</div>
|
| 362 |
)}
|
| 363 |
|
|
@@ -68,7 +68,7 @@ class ExplanationResult:
|
|
| 68 |
class TriggerExplainer:
|
| 69 |
"""Generates bilingual heat alert explanations using Claude with RAG."""
|
| 70 |
|
| 71 |
-
def __init__(self, api_key: Optional[str] = None, model: str = "claude-
|
| 72 |
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
| 73 |
self.model = model
|
| 74 |
self._client = None
|
|
|
|
| 68 |
class TriggerExplainer:
|
| 69 |
"""Generates bilingual heat alert explanations using Claude with RAG."""
|
| 70 |
|
| 71 |
+
def __init__(self, api_key: Optional[str] = None, model: str = "claude-haiku-4-5-20251001"):
|
| 72 |
self.api_key = api_key or os.environ.get("ANTHROPIC_API_KEY", "")
|
| 73 |
self.model = model
|
| 74 |
self._client = None
|
|
@@ -8,13 +8,14 @@ Each step has independent fallbacks — no cascading failures.
|
|
| 8 |
|
| 9 |
import asyncio
|
| 10 |
import logging
|
|
|
|
| 11 |
import time
|
| 12 |
import uuid
|
| 13 |
from dataclasses import dataclass, field
|
| 14 |
from datetime import datetime, timedelta
|
| 15 |
from pathlib import Path
|
| 16 |
|
| 17 |
-
from config import ZONES, ZONE_MAP
|
| 18 |
|
| 19 |
# Pipeline runs only on Dar es Salaam zones (where the neural model is trained).
|
| 20 |
# Other cities remain in config.py for the dashboard but don't run through the pipeline.
|
|
@@ -742,7 +743,17 @@ class HeatRiskPipeline:
|
|
| 742 |
)
|
| 743 |
|
| 744 |
try:
|
| 745 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 746 |
|
| 747 |
for trigger in self._triggers:
|
| 748 |
zone = ZONE_MAP.get(trigger.zone_id)
|
|
@@ -750,12 +761,12 @@ class HeatRiskPipeline:
|
|
| 750 |
continue
|
| 751 |
|
| 752 |
basis = self._basis_risk.get(trigger.zone_id, {})
|
| 753 |
-
# TemplateExplainer.explain is async — run it
|
| 754 |
explanation = asyncio.run(explainer.explain(trigger, zone, basis))
|
| 755 |
self._explanations.append(explanation)
|
| 756 |
total_tokens += getattr(explanation, "tokens_used", 0)
|
| 757 |
|
| 758 |
-
|
|
|
|
| 759 |
|
| 760 |
return StepResult(
|
| 761 |
step="explain", status="ok",
|
|
@@ -764,6 +775,7 @@ class HeatRiskPipeline:
|
|
| 764 |
details={
|
| 765 |
"explanations_generated": len(self._explanations),
|
| 766 |
"languages": ["en", "sw"],
|
|
|
|
| 767 |
"total_tokens": total_tokens,
|
| 768 |
"cost_usd": est_cost,
|
| 769 |
},
|
|
|
|
| 8 |
|
| 9 |
import asyncio
|
| 10 |
import logging
|
| 11 |
+
import os
|
| 12 |
import time
|
| 13 |
import uuid
|
| 14 |
from dataclasses import dataclass, field
|
| 15 |
from datetime import datetime, timedelta
|
| 16 |
from pathlib import Path
|
| 17 |
|
| 18 |
+
from config import ZONES, ZONE_MAP, EXPLANATION_MODE
|
| 19 |
|
| 20 |
# Pipeline runs only on Dar es Salaam zones (where the neural model is trained).
|
| 21 |
# Other cities remain in config.py for the dashboard but don't run through the pipeline.
|
|
|
|
| 743 |
)
|
| 744 |
|
| 745 |
try:
|
| 746 |
+
# Dispatch on EXPLANATION_MODE config flag.
|
| 747 |
+
# "template" — free, deterministic (default)
|
| 748 |
+
# "ai" — Claude Haiku 4.5 per trigger event. Scales with trigger
|
| 749 |
+
# events fired, not worker population — ~$0.10/wk even at
|
| 750 |
+
# 100K+ workers.
|
| 751 |
+
if EXPLANATION_MODE == "ai" and os.environ.get("ANTHROPIC_API_KEY"):
|
| 752 |
+
explainer = TriggerExplainer()
|
| 753 |
+
mode_used = "ai"
|
| 754 |
+
else:
|
| 755 |
+
explainer = TemplateExplainer()
|
| 756 |
+
mode_used = "template"
|
| 757 |
|
| 758 |
for trigger in self._triggers:
|
| 759 |
zone = ZONE_MAP.get(trigger.zone_id)
|
|
|
|
| 761 |
continue
|
| 762 |
|
| 763 |
basis = self._basis_risk.get(trigger.zone_id, {})
|
|
|
|
| 764 |
explanation = asyncio.run(explainer.explain(trigger, zone, basis))
|
| 765 |
self._explanations.append(explanation)
|
| 766 |
total_tokens += getattr(explanation, "tokens_used", 0)
|
| 767 |
|
| 768 |
+
# Haiku 4.5 pricing: $1/M input, $5/M output. Rough blended estimate.
|
| 769 |
+
est_cost = total_tokens * 3 / 1_000_000 if mode_used == "ai" else 0.0
|
| 770 |
|
| 771 |
return StepResult(
|
| 772 |
step="explain", status="ok",
|
|
|
|
| 775 |
details={
|
| 776 |
"explanations_generated": len(self._explanations),
|
| 777 |
"languages": ["en", "sw"],
|
| 778 |
+
"mode": mode_used,
|
| 779 |
"total_tokens": total_tokens,
|
| 780 |
"cost_usd": est_cost,
|
| 781 |
},
|