KSvend Claude Happy commited on
Commit
80d0ba0
·
1 Parent(s): c7136f3

docs: implementation plan for narrative-driven report redesign

Browse files

Generated with [Claude Code](https://claude.ai/code)
via [Happy](https://happy.engineering)

Co-Authored-By: Claude <noreply@anthropic.com>
Co-Authored-By: Happy <yesreply@happy.engineering>

docs/superpowers/plans/2026-04-03-report-redesign.md ADDED
@@ -0,0 +1,723 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Report Redesign Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Replace the status-dashboard PDF report with a narrative-driven report where EO indicators tell a connected story.
6
+
7
+ **Architecture:** New `app/outputs/narrative.py` module generates cross-indicator narratives and per-indicator interpretations using template-based pattern matching. Existing `app/outputs/report.py` is rewritten to use the new 4-section structure (The Place, Situation Assessment, Indicator Deep Dives, Technical Annex). The `generate_pdf_report()` signature stays the same — no changes to the worker or indicator code.
8
+
9
+ **Tech Stack:** Python, reportlab (existing), pytest
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-04-03-report-redesign-design.md`
12
+
13
+ ---
14
+
15
+ ### Task 1: Narrative Engine — Core Functions
16
+
17
+ **Files:**
18
+ - Create: `app/outputs/narrative.py`
19
+ - Create: `tests/test_narrative.py`
20
+
21
+ - [ ] **Step 1: Write failing tests for the narrative engine**
22
+
23
+ Create `tests/test_narrative.py`:
24
+
25
+ ```python
26
+ """Tests for app.outputs.narrative — cross-indicator narrative generation."""
27
+ from app.models import IndicatorResult, StatusLevel, TrendDirection, ConfidenceLevel
28
+
29
+
30
+ def _make_result(
31
+ indicator_id: str,
32
+ status: StatusLevel = StatusLevel.GREEN,
33
+ trend: TrendDirection = TrendDirection.STABLE,
34
+ data_source: str = "satellite",
35
+ headline: str = "Test headline",
36
+ summary: str = "Test summary.",
37
+ ) -> IndicatorResult:
38
+ return IndicatorResult(
39
+ indicator_id=indicator_id,
40
+ headline=headline,
41
+ status=status,
42
+ trend=trend,
43
+ confidence=ConfidenceLevel.HIGH,
44
+ map_layer_path="",
45
+ chart_data={"dates": ["2025-06"], "values": [0.5]},
46
+ summary=summary,
47
+ methodology="Test methodology.",
48
+ limitations=["Test limitation."],
49
+ data_source=data_source,
50
+ )
51
+
52
+
53
+ def test_generate_narrative_all_green():
54
+ from app.outputs.narrative import generate_narrative
55
+ results = [
56
+ _make_result("ndvi"),
57
+ _make_result("water"),
58
+ ]
59
+ narrative = generate_narrative(results)
60
+ assert "within normal ranges" in narrative.lower() or "stable" in narrative.lower()
61
+ assert len(narrative) > 20
62
+
63
+
64
+ def test_generate_narrative_drought_pattern():
65
+ from app.outputs.narrative import generate_narrative
66
+ results = [
67
+ _make_result("ndvi", status=StatusLevel.RED, trend=TrendDirection.DETERIORATING),
68
+ _make_result("rainfall", status=StatusLevel.RED, trend=TrendDirection.DETERIORATING),
69
+ ]
70
+ narrative = generate_narrative(results)
71
+ assert "drought" in narrative.lower() or "precipitation" in narrative.lower()
72
+
73
+
74
+ def test_generate_narrative_landuse_pattern():
75
+ from app.outputs.narrative import generate_narrative
76
+ results = [
77
+ _make_result("ndvi", status=StatusLevel.AMBER, trend=TrendDirection.DETERIORATING),
78
+ _make_result("buildup", status=StatusLevel.AMBER, trend=TrendDirection.DETERIORATING),
79
+ ]
80
+ narrative = generate_narrative(results)
81
+ assert "settlement" in narrative.lower() or "land-use" in narrative.lower()
82
+
83
+
84
+ def test_generate_narrative_placeholder_caveat():
85
+ from app.outputs.narrative import generate_narrative
86
+ results = [
87
+ _make_result("ndvi", data_source="placeholder"),
88
+ _make_result("water"),
89
+ ]
90
+ narrative = generate_narrative(results)
91
+ assert "placeholder" in narrative.lower() or "estimated" in narrative.lower() or "limited" in narrative.lower()
92
+
93
+
94
+ def test_generate_narrative_single_indicator():
95
+ from app.outputs.narrative import generate_narrative
96
+ results = [_make_result("ndvi", status=StatusLevel.AMBER, trend=TrendDirection.DETERIORATING)]
97
+ narrative = generate_narrative(results)
98
+ assert isinstance(narrative, str)
99
+ assert len(narrative) > 20
100
+
101
+
102
+ def test_get_interpretation_returns_nonempty():
103
+ from app.outputs.narrative import get_interpretation
104
+ for ind_id in ["ndvi", "water", "sar", "buildup", "fires", "rainfall"]:
105
+ for status in [StatusLevel.GREEN, StatusLevel.AMBER, StatusLevel.RED]:
106
+ result = get_interpretation(ind_id, status)
107
+ assert isinstance(result, str)
108
+ assert len(result) > 10, f"Empty interpretation for {ind_id}/{status}"
109
+ ```
110
+
111
+ - [ ] **Step 2: Run tests to verify they fail**
112
+
113
+ Run: `pytest tests/test_narrative.py -v`
114
+ Expected: FAIL with ModuleNotFoundError (narrative.py doesn't exist yet)
115
+
116
+ - [ ] **Step 3: Implement the narrative engine**
117
+
118
+ Create `app/outputs/narrative.py`:
119
+
120
+ ```python
121
+ """Cross-indicator narrative generation for Aperture reports."""
122
+ from __future__ import annotations
123
+
124
+ from typing import Sequence
125
+
126
+ from app.models import IndicatorResult, StatusLevel, TrendDirection
127
+
128
+
129
+ # --- Per-indicator interpretation templates ---
130
+ _INTERPRETATIONS: dict[tuple[str, StatusLevel], str] = {
131
+ ("ndvi", StatusLevel.RED): "A decline of this magnitude typically indicates severe crop stress, overgrazing, or drought impact.",
132
+ ("ndvi", StatusLevel.AMBER): "Moderate vegetation decline may reflect seasonal stress or early-stage degradation.",
133
+ ("ndvi", StatusLevel.GREEN): "Vegetation cover is within the normal range for this area and season.",
134
+ ("water", StatusLevel.RED): "Significant water extent change beyond the baseline suggests major flooding or hydrological disruption.",
135
+ ("water", StatusLevel.AMBER): "Moderate water extent changes may indicate seasonal flooding or irrigation changes.",
136
+ ("water", StatusLevel.GREEN): "Water extent is within normal seasonal variation.",
137
+ ("sar", StatusLevel.RED): "Major SAR backscatter anomalies indicate significant ground surface changes \u2014 possible flooding, construction, or deforestation.",
138
+ ("sar", StatusLevel.AMBER): "Moderate SAR changes may reflect seasonal soil moisture variation or gradual land-use change.",
139
+ ("sar", StatusLevel.GREEN): "SAR backscatter is within the normal range, indicating stable ground conditions.",
140
+ ("buildup", StatusLevel.RED): "Rapid settlement expansion at this rate suggests significant population displacement or unplanned urban growth.",
141
+ ("buildup", StatusLevel.AMBER): "Moderate settlement growth detected, consistent with gradual urbanization.",
142
+ ("buildup", StatusLevel.GREEN): "Settlement extent is stable relative to the baseline period.",
143
+ ("fires", StatusLevel.RED): "Active fire density at critical levels \u2014 indicates widespread burning requiring immediate attention.",
144
+ ("fires", StatusLevel.AMBER): "Elevated fire activity detected, likely agricultural burning or localized wildfire.",
145
+ ("fires", StatusLevel.GREEN): "Fire activity is within normal seasonal patterns.",
146
+ ("rainfall", StatusLevel.RED): "Severe precipitation deficit against the long-term average is consistent with drought conditions.",
147
+ ("rainfall", StatusLevel.AMBER): "Below-average precipitation may lead to crop stress if the deficit persists.",
148
+ ("rainfall", StatusLevel.GREEN): "Precipitation is within normal ranges for the season.",
149
+ ("lst", StatusLevel.RED): "Surface temperatures are critically elevated, indicating severe thermal stress.",
150
+ ("lst", StatusLevel.AMBER): "Moderately elevated surface temperatures may affect crop health and water availability.",
151
+ ("lst", StatusLevel.GREEN): "Surface temperatures are within the normal seasonal range.",
152
+ ("nightlights", StatusLevel.RED): "Significant nighttime light changes indicate major disruption to infrastructure or population activity.",
153
+ ("nightlights", StatusLevel.AMBER): "Moderate nighttime light changes may reflect economic shifts or partial service disruption.",
154
+ ("nightlights", StatusLevel.GREEN): "Nighttime light patterns are stable, suggesting no major disruption.",
155
+ }
156
+
157
+ # --- Cross-indicator pattern rules ---
158
+ # Each rule: (required indicator statuses, narrative sentence)
159
+ _CROSS_PATTERNS: list[tuple[dict[str, set[StatusLevel]], str]] = [
160
+ (
161
+ {"ndvi": {StatusLevel.RED, StatusLevel.AMBER}, "rainfall": {StatusLevel.RED, StatusLevel.AMBER}},
162
+ "Vegetation decline is consistent with reduced precipitation, suggesting drought-driven stress.",
163
+ ),
164
+ (
165
+ {"ndvi": {StatusLevel.RED, StatusLevel.AMBER}, "buildup": {StatusLevel.RED, StatusLevel.AMBER}},
166
+ "Vegetation loss coincides with settlement expansion, indicating possible land-use conversion.",
167
+ ),
168
+ (
169
+ {"ndvi": {StatusLevel.RED, StatusLevel.AMBER}, "sar": {StatusLevel.RED, StatusLevel.AMBER}},
170
+ "Vegetation decline paired with SAR backscatter anomalies may indicate flood damage or soil saturation.",
171
+ ),
172
+ (
173
+ {"water": {StatusLevel.RED, StatusLevel.AMBER}, "sar": {StatusLevel.RED, StatusLevel.AMBER}},
174
+ "Increased water extent and SAR signal changes suggest flooding or waterlogging.",
175
+ ),
176
+ (
177
+ {"fires": {StatusLevel.RED, StatusLevel.AMBER}, "ndvi": {StatusLevel.RED, StatusLevel.AMBER}},
178
+ "Active fire detections combined with vegetation decline indicate burning-driven land cover change.",
179
+ ),
180
+ ]
181
+
182
+ _LEAD_TEMPLATES = {
183
+ StatusLevel.RED: "The situation shows critical concern across one or more indicators.",
184
+ StatusLevel.AMBER: "The situation shows elevated concern requiring monitoring.",
185
+ StatusLevel.GREEN: "All indicators are within normal ranges for this area and period.",
186
+ }
187
+
188
+
189
+ def get_interpretation(indicator_id: str, status: StatusLevel) -> str:
190
+ """Return a 1-2 sentence interpretation for the given indicator and status."""
191
+ return _INTERPRETATIONS.get(
192
+ (indicator_id, status),
193
+ f"{indicator_id.replace('_', ' ').title()} status is {status.value}.",
194
+ )
195
+
196
+
197
+ def generate_narrative(results: Sequence[IndicatorResult]) -> str:
198
+ """Generate a cross-indicator narrative paragraph from indicator results."""
199
+ if not results:
200
+ return "No indicator data available for narrative generation."
201
+
202
+ parts: list[str] = []
203
+
204
+ # 1. Lead sentence — worst status drives framing
205
+ worst = max(
206
+ (r.status for r in results),
207
+ key=lambda s: [StatusLevel.GREEN, StatusLevel.AMBER, StatusLevel.RED].index(s),
208
+ )
209
+ parts.append(_LEAD_TEMPLATES[worst])
210
+
211
+ # 2. Per-indicator sentences — key metric from headline
212
+ for r in results:
213
+ parts.append(f"{r.headline}.")
214
+
215
+ # 3. Cross-indicator connection — first matching pattern
216
+ result_map = {r.indicator_id: r.status for r in results}
217
+ for required, sentence in _CROSS_PATTERNS:
218
+ if all(
219
+ ind_id in result_map and result_map[ind_id] in allowed_statuses
220
+ for ind_id, allowed_statuses in required.items()
221
+ ):
222
+ parts.append(sentence)
223
+ break
224
+
225
+ # 4. Placeholder caveat
226
+ placeholder_count = sum(1 for r in results if r.data_source == "placeholder")
227
+ if placeholder_count:
228
+ parts.append(
229
+ f"{placeholder_count} indicator(s) used estimated data "
230
+ "\u2014 cross-indicator interpretation is limited."
231
+ )
232
+
233
+ return " ".join(parts)
234
+ ```
235
+
236
+ - [ ] **Step 4: Run tests to verify they pass**
237
+
238
+ Run: `pytest tests/test_narrative.py -v`
239
+ Expected: All 6 tests PASS
240
+
241
+ - [ ] **Step 5: Commit**
242
+
243
+ ```bash
244
+ git add app/outputs/narrative.py tests/test_narrative.py
245
+ git commit -m "feat: add cross-indicator narrative engine with interpretation templates"
246
+ ```
247
+
248
+ ---
249
+
250
+ ### Task 2: Rewrite PDF Report — The Place + Situation Assessment
251
+
252
+ **Files:**
253
+ - Modify: `app/outputs/report.py:226-556` (replace `generate_pdf_report` body)
254
+ - Test: `tests/test_report.py`
255
+
256
+ - [ ] **Step 1: Run existing report test to establish baseline**
257
+
258
+ Run: `pytest tests/test_report.py -v`
259
+ Expected: PASS
260
+
261
+ - [ ] **Step 2: Rewrite `generate_pdf_report` with new structure**
262
+
263
+ Replace the entire body of `generate_pdf_report` in `app/outputs/report.py` (lines 250-555) with the new structure. Keep the function signature, styles, and page template helpers (`_build_styles`, `_status_badge_table`, `_on_page`, `_indicator_label`) unchanged. Replace the `_indicator_block` function and the `story` construction inside `generate_pdf_report`.
264
+
265
+ Replace the `_indicator_block` function (lines 163-223) with:
266
+
267
+ ```python
268
+ def _indicator_block(result: IndicatorResult, styles: dict, map_path: str = "", chart_path: str = "") -> list:
269
+ """Build the flowables for a single indicator deep-dive section."""
270
+ from reportlab.platypus import Image
271
+ from app.outputs.narrative import get_interpretation
272
+
273
+ elements = []
274
+
275
+ # Badge + headline row
276
+ badge = _status_badge_table(result.status, styles)
277
+ headline = Paragraph(result.headline, styles["indicator_headline"])
278
+ row = Table(
279
+ [[badge, headline]],
280
+ colWidths=[2.2 * cm, None],
281
+ rowHeights=[None],
282
+ )
283
+ row.setStyle(TableStyle([
284
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
285
+ ("LEFTPADDING", (1, 0), (1, 0), 8),
286
+ ("TOPPADDING", (0, 0), (-1, -1), 0),
287
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 0),
288
+ ]))
289
+ elements.append(row)
290
+ elements.append(Spacer(1, 3 * mm))
291
+
292
+ # Placeholder data warning
293
+ if result.data_source == "placeholder":
294
+ elements.append(Paragraph(
295
+ '<font color="#CA5D0F"><b>\u26a0 Placeholder data</b> \u2014 real satellite data was '
296
+ 'unavailable for this indicator. Results are illustrative only.</font>',
297
+ styles["body_muted"],
298
+ ))
299
+ elements.append(Spacer(1, 2 * mm))
300
+
301
+ # Map and chart — side by side if both exist, otherwise full width
302
+ has_map = map_path and os.path.exists(map_path)
303
+ has_chart = chart_path and os.path.exists(chart_path)
304
+ if has_map and has_chart:
305
+ map_img = Image(map_path, width=8 * cm, height=6 * cm)
306
+ chart_img = Image(chart_path, width=8 * cm, height=6 * cm)
307
+ img_row = Table([[map_img, chart_img]], colWidths=[8.5 * cm, 8.5 * cm])
308
+ img_row.setStyle(TableStyle([
309
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
310
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
311
+ ]))
312
+ elements.append(img_row)
313
+ elif has_map:
314
+ img = Image(map_path, width=12 * cm, height=9 * cm)
315
+ img.hAlign = "CENTER"
316
+ elements.append(img)
317
+ elif has_chart:
318
+ img = Image(chart_path, width=12 * cm, height=6 * cm)
319
+ img.hAlign = "CENTER"
320
+ elements.append(img)
321
+ elements.append(Spacer(1, 3 * mm))
322
+
323
+ # "What the data shows"
324
+ elements.append(Paragraph("<b>What the data shows</b>", styles["body"]))
325
+ elements.append(Paragraph(result.summary, styles["body"]))
326
+ elements.append(Spacer(1, 2 * mm))
327
+
328
+ # "What this means"
329
+ interpretation = get_interpretation(result.indicator_id, result.status)
330
+ elements.append(Paragraph("<b>What this means</b>", styles["body"]))
331
+ elements.append(Paragraph(interpretation, styles["body"]))
332
+ elements.append(Spacer(1, 2 * mm))
333
+
334
+ # Data quality line
335
+ confidence_str = result.confidence.value.capitalize()
336
+ trend_str = result.trend.value.capitalize()
337
+ quality_line = (
338
+ f"Confidence: <b>{confidence_str}</b> &nbsp;|&nbsp; "
339
+ f"Trend: <b>{trend_str}</b> &nbsp;|&nbsp; "
340
+ f"Data source: <b>{result.data_source}</b>"
341
+ )
342
+ elements.append(Paragraph(quality_line, styles["body_muted"]))
343
+
344
+ elements.append(Spacer(1, 4 * mm))
345
+ elements.append(
346
+ HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#D8D5CF"))
347
+ )
348
+ elements.append(Spacer(1, 2 * mm))
349
+ return elements
350
+ ```
351
+
352
+ Now replace the story construction inside `generate_pdf_report` (everything after `story = []` up to `doc.build(story)`):
353
+
354
+ ```python
355
+ story = []
356
+
357
+ from datetime import datetime as _dt, timezone as _tz
358
+ from app.outputs.narrative import generate_narrative
359
+
360
+ generated_at = _dt.now(_tz.utc).strftime("%Y-%m-%d %H:%M UTC")
361
+
362
+ # ------------------------------------------------------------------ #
363
+ # Section 1: The Place #
364
+ # ------------------------------------------------------------------ #
365
+ story.append(Paragraph("MERLx Aperture \u2014 Situation Report", styles["title"]))
366
+ story.append(Paragraph(aoi.name, styles["subtitle"]))
367
+ story.append(Spacer(1, 2 * mm))
368
+
369
+ # True-color overview map
370
+ if overview_map_path and os.path.exists(overview_map_path):
371
+ from reportlab.platypus import Image
372
+ img = Image(overview_map_path, width=14 * cm, height=10.5 * cm)
373
+ img.hAlign = "CENTER"
374
+ story.append(img)
375
+ story.append(Spacer(1, 3 * mm))
376
+
377
+ story.append(
378
+ Paragraph(
379
+ f"Analysis period: {time_range.start} \u2013 {time_range.end}",
380
+ styles["body_muted"],
381
+ )
382
+ )
383
+ story.append(
384
+ Paragraph(
385
+ f"Bounding box: {aoi.bbox[0]}\u00b0E, {aoi.bbox[1]}\u00b0N \u2013 "
386
+ f"{aoi.bbox[2]}\u00b0E, {aoi.bbox[3]}\u00b0N &nbsp;|&nbsp; "
387
+ f"Area: {aoi.area_km2:.1f} km\u00b2 &nbsp;|&nbsp; Generated: {generated_at}",
388
+ styles["body_muted"],
389
+ )
390
+ )
391
+
392
+ # Data sources summary
393
+ sources = set()
394
+ for r in results:
395
+ if "Sentinel-2" in r.methodology:
396
+ sources.add("Sentinel-2")
397
+ if "Sentinel-1" in r.methodology or "SAR" in r.methodology:
398
+ sources.add("Sentinel-1")
399
+ if "FIRMS" in r.methodology:
400
+ sources.add("NASA FIRMS")
401
+ if "CHIRPS" in r.methodology:
402
+ sources.add("CHIRPS")
403
+ if "Sentinel-3" in r.methodology or "SLSTR" in r.methodology:
404
+ sources.add("Sentinel-3")
405
+ if "VIIRS" in r.methodology:
406
+ sources.add("VIIRS")
407
+ if sources:
408
+ source_str = ", ".join(sorted(sources))
409
+ story.append(Paragraph(
410
+ f"Based on {len(results)} indicator(s) using {source_str}.",
411
+ styles["body_muted"],
412
+ ))
413
+
414
+ story.append(Spacer(1, 4 * mm))
415
+ story.append(HRFlowable(width="100%", thickness=1, color=INK_MUTED))
416
+ story.append(Spacer(1, 6 * mm))
417
+
418
+ # ------------------------------------------------------------------ #
419
+ # Section 2: Situation Assessment #
420
+ # ------------------------------------------------------------------ #
421
+ story.append(Paragraph("Situation Assessment", styles["section_heading"]))
422
+ story.append(Spacer(1, 2 * mm))
423
+
424
+ # Composite badge + headline
425
+ if overview_score:
426
+ composite_status_enum = {
427
+ "GREEN": StatusLevel.GREEN,
428
+ "AMBER": StatusLevel.AMBER,
429
+ "RED": StatusLevel.RED,
430
+ }.get(overview_score.get("status", "RED"), StatusLevel.RED)
431
+
432
+ badge = _status_badge_table(composite_status_enum, styles)
433
+ ov_headline = Paragraph(
434
+ overview_score.get("headline", ""),
435
+ styles["indicator_headline"],
436
+ )
437
+ row = Table([[badge, ov_headline]], colWidths=[2.2 * cm, None])
438
+ row.setStyle(TableStyle([
439
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
440
+ ("LEFTPADDING", (1, 0), (1, 0), 8),
441
+ ("TOPPADDING", (0, 0), (-1, -1), 0),
442
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 0),
443
+ ]))
444
+ story.append(row)
445
+ story.append(Spacer(1, 4 * mm))
446
+
447
+ # Cross-indicator narrative
448
+ narrative = generate_narrative(results)
449
+ story.append(Paragraph(narrative, styles["body"]))
450
+ story.append(Spacer(1, 4 * mm))
451
+
452
+ # Compact summary table
453
+ summary_header = [
454
+ Paragraph("<b>Indicator</b>", styles["body"]),
455
+ Paragraph("<b>Status</b>", styles["body"]),
456
+ Paragraph("<b>Trend</b>", styles["body"]),
457
+ Paragraph("<b>Confidence</b>", styles["body"]),
458
+ Paragraph("<b>Headline</b>", styles["body"]),
459
+ ]
460
+ summary_rows = [summary_header]
461
+ for result in results:
462
+ label = _indicator_label(result.indicator_id)
463
+ status_cell = Paragraph(
464
+ f'<font color="white"><b>{STATUS_LABELS[result.status]}</b></font>',
465
+ ParagraphStyle(
466
+ "ov_badge",
467
+ fontName="Helvetica-Bold",
468
+ fontSize=7,
469
+ textColor=colors.white,
470
+ alignment=TA_CENTER,
471
+ ),
472
+ )
473
+ summary_rows.append([
474
+ Paragraph(label, styles["body_muted"]),
475
+ status_cell,
476
+ Paragraph(result.trend.value.capitalize(), styles["body_muted"]),
477
+ Paragraph(result.confidence.value.capitalize(), styles["body_muted"]),
478
+ Paragraph(result.headline[:80], styles["body_muted"]),
479
+ ])
480
+
481
+ ov_col_w = (PAGE_W - 2 * MARGIN)
482
+ ov_table = Table(
483
+ summary_rows,
484
+ colWidths=[ov_col_w * 0.15, ov_col_w * 0.10, ov_col_w * 0.12, ov_col_w * 0.13, ov_col_w * 0.50],
485
+ )
486
+ ov_ts = TableStyle([
487
+ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8E6E0")),
488
+ ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor(_SHELL_HEX)]),
489
+ ("GRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#D8D5CF")),
490
+ ("TOPPADDING", (0, 0), (-1, -1), 3),
491
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
492
+ ("LEFTPADDING", (0, 0), (-1, -1), 4),
493
+ ("RIGHTPADDING", (0, 0), (-1, -1), 4),
494
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
495
+ ("ALIGN", (1, 1), (1, -1), "CENTER"),
496
+ ])
497
+ for row_idx, result in enumerate(results, start=1):
498
+ ov_ts.add("BACKGROUND", (1, row_idx), (1, row_idx), STATUS_COLORS[result.status])
499
+ ov_table.setStyle(ov_ts)
500
+ story.append(ov_table)
501
+
502
+ story.append(Spacer(1, 6 * mm))
503
+ story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#D8D5CF")))
504
+ story.append(Spacer(1, 4 * mm))
505
+
506
+ # ------------------------------------------------------------------ #
507
+ # Section 3: Indicator Deep Dives #
508
+ # ------------------------------------------------------------------ #
509
+ story.append(Paragraph("Indicator Detail", styles["section_heading"]))
510
+ story.append(Spacer(1, 2 * mm))
511
+
512
+ # Build chart path lookup from results_dir (same directory as output_path)
513
+ results_dir = os.path.dirname(output_path)
514
+ for result in results:
515
+ indicator_label = _indicator_label(result.indicator_id)
516
+ map_path = (indicator_map_paths or {}).get(result.indicator_id, "")
517
+ chart_path = os.path.join(results_dir, f"{result.indicator_id}_chart.png")
518
+ if not os.path.exists(chart_path):
519
+ chart_path = ""
520
+ block = [Paragraph(indicator_label, styles["section_heading"])]
521
+ block += _indicator_block(result, styles, map_path=map_path, chart_path=chart_path)
522
+ story.append(KeepTogether(block))
523
+
524
+ # ------------------------------------------------------------------ #
525
+ # Section 4: Technical Annex #
526
+ # ------------------------------------------------------------------ #
527
+ story.append(Paragraph("Technical Annex", styles["section_heading"]))
528
+ story.append(Spacer(1, 2 * mm))
529
+
530
+ # Methodology per indicator
531
+ story.append(Paragraph("<b>Methodology</b>", styles["body"]))
532
+ for result in results:
533
+ indicator_label = _indicator_label(result.indicator_id)
534
+ story.append(
535
+ Paragraph(
536
+ f"<b>{indicator_label}:</b> {result.methodology}",
537
+ styles["body"],
538
+ )
539
+ )
540
+ story.append(Spacer(1, 4 * mm))
541
+
542
+ # Limitations
543
+ all_limitations = []
544
+ for result in results:
545
+ for lim in result.limitations:
546
+ if lim not in all_limitations:
547
+ all_limitations.append(lim)
548
+ if all_limitations:
549
+ story.append(Paragraph("<b>Limitations</b>", styles["body"]))
550
+ for lim in all_limitations:
551
+ story.append(Paragraph(f"\u2022 {lim}", styles["limitation"]))
552
+ story.append(Spacer(1, 4 * mm))
553
+
554
+ # Disclaimer
555
+ story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#D8D5CF")))
556
+ story.append(Spacer(1, 3 * mm))
557
+ disclaimer = (
558
+ "This report has been generated automatically by MERLx Aperture using satellite "
559
+ "remote sensing data. Results are intended to support humanitarian situation analysis "
560
+ "and should be interpreted alongside ground-truth information and expert judgement. "
561
+ "MERLx Aperture makes no warranty as to the accuracy or completeness of the data presented."
562
+ )
563
+ story.append(Paragraph("Disclaimer", styles["section_heading"]))
564
+ story.append(Paragraph(disclaimer, styles["body_muted"]))
565
+
566
+ # ------------------------------------------------------------------ #
567
+ # Build PDF #
568
+ # ------------------------------------------------------------------ #
569
+ doc.build(story)
570
+ ```
571
+
572
+ - [ ] **Step 3: Update `_indicator_block` signature**
573
+
574
+ The `_indicator_block` function now accepts an optional `chart_path` parameter. Update the function signature and body as shown in Step 2. Remove the old version entirely.
575
+
576
+ - [ ] **Step 4: Run report test**
577
+
578
+ Run: `pytest tests/test_report.py -v`
579
+ Expected: PASS (generates PDF > 5000 bytes with new structure)
580
+
581
+ - [ ] **Step 5: Run full test suite**
582
+
583
+ Run: `pytest --tb=short -q`
584
+ Expected: All tests pass
585
+
586
+ - [ ] **Step 6: Commit**
587
+
588
+ ```bash
589
+ git add app/outputs/report.py
590
+ git commit -m "feat: rewrite PDF report with narrative structure (The Place, Assessment, Deep Dives, Annex)"
591
+ ```
592
+
593
+ ---
594
+
595
+ ### Task 3: Update Report Test for New Content
596
+
597
+ **Files:**
598
+ - Modify: `tests/test_report.py`
599
+
600
+ - [ ] **Step 1: Update test to verify new structure and removed sections**
601
+
602
+ Replace `tests/test_report.py` with:
603
+
604
+ ```python
605
+ import pytest
606
+ import tempfile
607
+ import os
608
+ from datetime import date
609
+ from app.outputs.report import generate_pdf_report
610
+ from app.models import AOI, TimeRange, IndicatorResult, StatusLevel, TrendDirection, ConfidenceLevel
611
+
612
+
613
+ def _make_result(indicator_id, status=StatusLevel.AMBER, headline="Test headline"):
614
+ return IndicatorResult(
615
+ indicator_id=indicator_id,
616
+ headline=headline,
617
+ status=status,
618
+ trend=TrendDirection.DETERIORATING,
619
+ confidence=ConfidenceLevel.HIGH,
620
+ map_layer_path="",
621
+ chart_data={"dates": ["2025-06"], "values": [1]},
622
+ summary="Test summary of data.",
623
+ methodology="Sentinel-2 NDVI at 100m resolution.",
624
+ limitations=["Cloud cover."],
625
+ )
626
+
627
+
628
+ def test_generate_pdf_report():
629
+ aoi = AOI(name="Khartoum North", bbox=[32.45, 15.65, 32.65, 15.80])
630
+ time_range = TimeRange(start=date(2025, 3, 1), end=date(2026, 3, 1))
631
+ results = [
632
+ _make_result("fires", headline="3 fire events detected"),
633
+ _make_result("ndvi", status=StatusLevel.RED, headline="34% decline vs. baseline"),
634
+ ]
635
+ with tempfile.TemporaryDirectory() as tmpdir:
636
+ out_path = os.path.join(tmpdir, "report.pdf")
637
+ generate_pdf_report(aoi=aoi, time_range=time_range, results=results, output_path=out_path)
638
+ assert os.path.exists(out_path)
639
+ assert os.path.getsize(out_path) > 5000
640
+
641
+
642
+ def test_generate_pdf_report_single_indicator():
643
+ aoi = AOI(name="Test Area", bbox=[32.0, 15.0, 33.0, 16.0])
644
+ time_range = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
645
+ results = [_make_result("ndvi")]
646
+ with tempfile.TemporaryDirectory() as tmpdir:
647
+ out_path = os.path.join(tmpdir, "report.pdf")
648
+ generate_pdf_report(aoi=aoi, time_range=time_range, results=results, output_path=out_path)
649
+ assert os.path.exists(out_path)
650
+ assert os.path.getsize(out_path) > 3000
651
+
652
+
653
+ def test_generate_pdf_report_with_overview_score():
654
+ aoi = AOI(name="Test Area", bbox=[32.0, 15.0, 33.0, 16.0])
655
+ time_range = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
656
+ results = [
657
+ _make_result("ndvi", status=StatusLevel.RED),
658
+ _make_result("water", status=StatusLevel.GREEN),
659
+ ]
660
+ overview_score = {
661
+ "score": 45,
662
+ "status": "AMBER",
663
+ "headline": "Area conditions: AMBER (score 45/100)",
664
+ }
665
+ with tempfile.TemporaryDirectory() as tmpdir:
666
+ out_path = os.path.join(tmpdir, "report.pdf")
667
+ generate_pdf_report(
668
+ aoi=aoi, time_range=time_range, results=results,
669
+ output_path=out_path, overview_score=overview_score,
670
+ )
671
+ assert os.path.exists(out_path)
672
+ assert os.path.getsize(out_path) > 5000
673
+
674
+
675
+ def test_generate_pdf_report_with_placeholder():
676
+ aoi = AOI(name="Test Area", bbox=[32.0, 15.0, 33.0, 16.0])
677
+ time_range = TimeRange(start=date(2025, 1, 1), end=date(2025, 12, 31))
678
+ results = [_make_result("ndvi")]
679
+ results[0].data_source = "placeholder"
680
+ with tempfile.TemporaryDirectory() as tmpdir:
681
+ out_path = os.path.join(tmpdir, "report.pdf")
682
+ generate_pdf_report(aoi=aoi, time_range=time_range, results=results, output_path=out_path)
683
+ assert os.path.exists(out_path)
684
+ assert os.path.getsize(out_path) > 3000
685
+ ```
686
+
687
+ - [ ] **Step 2: Run updated tests**
688
+
689
+ Run: `pytest tests/test_report.py -v`
690
+ Expected: All 4 tests PASS
691
+
692
+ - [ ] **Step 3: Run full test suite**
693
+
694
+ Run: `pytest --tb=short -q`
695
+ Expected: All tests pass
696
+
697
+ - [ ] **Step 4: Commit**
698
+
699
+ ```bash
700
+ git add tests/test_report.py
701
+ git commit -m "test: update report tests for new narrative structure"
702
+ ```
703
+
704
+ ---
705
+
706
+ ### Task 4: Verify Full Integration
707
+
708
+ **Files:** None (verification only)
709
+
710
+ - [ ] **Step 1: Run full test suite**
711
+
712
+ Run: `pytest --tb=short -q`
713
+ Expected: All tests pass.
714
+
715
+ - [ ] **Step 2: Spot-check narrative module**
716
+
717
+ Run: `python -c "from app.outputs.narrative import generate_narrative, get_interpretation; from app.models import StatusLevel; print(get_interpretation('ndvi', StatusLevel.RED)); print('OK')"`
718
+ Expected: Prints interpretation text and "OK".
719
+
720
+ - [ ] **Step 3: Spot-check report imports**
721
+
722
+ Run: `python -c "from app.outputs.report import generate_pdf_report; print('OK')"`
723
+ Expected: Prints "OK" with no import errors.