KSvend Claude Happy commited on
Commit
dfdabfe
·
1 Parent(s): 31dea57

feat: rewrite PDF report with narrative structure (The Place, Assessment, Deep Dives, Annex)

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>

Files changed (1) hide show
  1. app/outputs/report.py +188 -181
app/outputs/report.py CHANGED
@@ -160,8 +160,16 @@ def _status_badge_table(status: StatusLevel, styles: dict) -> Table:
160
  return t
161
 
162
 
163
- def _indicator_block(result: IndicatorResult, styles: dict, map_path: str = "") -> list:
 
 
 
 
 
164
  """Build the flowables for a single indicator section."""
 
 
 
165
  elements = []
166
 
167
  # Badge + headline row
@@ -190,30 +198,53 @@ def _indicator_block(result: IndicatorResult, styles: dict, map_path: str = "")
190
  ))
191
  elements.append(Spacer(1, 2 * mm))
192
 
193
- # Confidence + trend meta line
194
- meta = (
195
- f"Confidence: <b>{result.confidence.value.capitalize()}</b> &nbsp; | &nbsp; "
196
- f"Trend: <b>{result.trend.value.capitalize()}</b>"
197
- )
198
- elements.append(Paragraph(meta, styles["body_muted"]))
199
- elements.append(Spacer(1, 2 * mm))
200
 
201
- # Indicator map
202
- if map_path and os.path.exists(map_path):
203
- from reportlab.platypus import Image
 
 
 
 
 
 
 
 
 
 
 
 
 
204
  img = Image(map_path, width=12 * cm, height=9 * cm)
205
  img.hAlign = "CENTER"
206
  elements.append(img)
207
  elements.append(Spacer(1, 2 * mm))
 
 
 
 
 
208
 
209
- # Summary
 
210
  elements.append(Paragraph(result.summary, styles["body"]))
211
 
212
- # Limitations
213
- if result.limitations:
214
- elements.append(Paragraph("Limitations:", styles["body_muted"]))
215
- for lim in result.limitations:
216
- elements.append(Paragraph(f"\u2022 {lim}", styles["limitation"]))
 
 
 
 
 
 
 
 
217
 
218
  elements.append(Spacer(1, 4 * mm))
219
  elements.append(
@@ -223,6 +254,19 @@ def _indicator_block(result: IndicatorResult, styles: dict, map_path: str = "")
223
  return elements
224
 
225
 
 
 
 
 
 
 
 
 
 
 
 
 
 
226
  def generate_pdf_report(
227
  *,
228
  aoi: AOI,
@@ -247,6 +291,8 @@ def generate_pdf_report(
247
  output_path:
248
  Absolute path where the PDF should be saved.
249
  """
 
 
250
  styles = _build_styles()
251
  PAGE_W, PAGE_H = A4
252
  MARGIN = 2 * cm
@@ -282,16 +328,30 @@ def generate_pdf_report(
282
  )
283
  doc.pageBackgrounds = [colors.white]
284
 
285
- story = []
286
-
287
- # ------------------------------------------------------------------ #
288
- # Title block #
289
- # ------------------------------------------------------------------ #
290
  from datetime import datetime as _dt, timezone as _tz
291
  generated_at = _dt.now(_tz.utc).strftime("%Y-%m-%d %H:%M UTC")
292
 
 
 
 
 
 
 
 
 
293
  story.append(Paragraph("MERLx Aperture — Situation Report", styles["title"]))
294
  story.append(Paragraph(aoi.name, styles["subtitle"]))
 
 
 
 
 
 
 
 
 
 
 
295
  story.append(
296
  Paragraph(
297
  f"Analysis period: {time_range.start} \u2013 {time_range.end}",
@@ -306,25 +366,28 @@ def generate_pdf_report(
306
  styles["body_muted"],
307
  )
308
  )
 
 
 
 
 
 
 
 
 
 
309
  story.append(Spacer(1, 4 * mm))
310
  story.append(HRFlowable(width="100%", thickness=1, color=INK_MUTED))
311
  story.append(Spacer(1, 6 * mm))
312
 
313
- # ------------------------------------------------------------------ #
314
- # Visual Overview #
315
- # ------------------------------------------------------------------ #
316
- if overview_score:
317
- story.append(Paragraph("Area Overview", styles["section_heading"]))
318
-
319
- # Overview map (full width)
320
- if overview_map_path and os.path.exists(overview_map_path):
321
- from reportlab.platypus import Image
322
- img = Image(overview_map_path, width=14 * cm, height=10.5 * cm)
323
- img.hAlign = "CENTER"
324
- story.append(img)
325
- story.append(Spacer(1, 3 * mm))
326
 
327
- # Composite score badge + headline
 
328
  composite_status_enum = {
329
  "GREEN": StatusLevel.GREEN,
330
  "AMBER": StatusLevel.AMBER,
@@ -349,178 +412,111 @@ def generate_pdf_report(
349
  story.append(row)
350
  story.append(Spacer(1, 4 * mm))
351
 
352
- # Summary table: all indicators in compact grid
353
- summary_header = [
354
- Paragraph("<b>Indicator</b>", styles["body"]),
355
- Paragraph("<b>Status</b>", styles["body"]),
356
- Paragraph("<b>Trend</b>", styles["body"]),
357
- Paragraph("<b>Confidence</b>", styles["body"]),
358
- Paragraph("<b>Headline</b>", styles["body"]),
359
- ]
360
- summary_rows = [summary_header]
361
- for result in results:
362
- label = _indicator_label(result.indicator_id)
363
- status_color = STATUS_COLORS[result.status]
364
- status_cell = Paragraph(
365
- f'<font color="white"><b>{STATUS_LABELS[result.status]}</b></font>',
366
- ParagraphStyle(
367
- "ov_badge",
368
- fontName="Helvetica-Bold",
369
- fontSize=7,
370
- textColor=colors.white,
371
- alignment=TA_CENTER,
372
- ),
373
- )
374
- summary_rows.append([
375
- Paragraph(label, styles["body_muted"]),
376
- status_cell,
377
- Paragraph(result.trend.value.capitalize(), styles["body_muted"]),
378
- Paragraph(result.confidence.value.capitalize(), styles["body_muted"]),
379
- Paragraph(result.headline[:80], styles["body_muted"]),
380
- ])
381
-
382
- ov_col_w = (PAGE_W - 2 * MARGIN)
383
- ov_table = Table(
384
- summary_rows,
385
- 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],
386
- )
387
- ov_ts = TableStyle([
388
- ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8E6E0")),
389
- ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor(_SHELL_HEX)]),
390
- ("GRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#D8D5CF")),
391
- ("TOPPADDING", (0, 0), (-1, -1), 3),
392
- ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
393
- ("LEFTPADDING", (0, 0), (-1, -1), 4),
394
- ("RIGHTPADDING", (0, 0), (-1, -1), 4),
395
- ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
396
- ("ALIGN", (1, 1), (1, -1), "CENTER"),
397
- ])
398
- for row_idx, result in enumerate(results, start=1):
399
- ov_ts.add("BACKGROUND", (1, row_idx), (1, row_idx), STATUS_COLORS[result.status])
400
- ov_table.setStyle(ov_ts)
401
- story.append(ov_table)
402
-
403
- story.append(Spacer(1, 6 * mm))
404
- story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#D8D5CF")))
405
- story.append(Spacer(1, 4 * mm))
406
-
407
- # ------------------------------------------------------------------ #
408
- # How to Read This Report #
409
- # ------------------------------------------------------------------ #
410
- story.append(Paragraph("How to Read This Report", styles["section_heading"]))
411
- how_to = (
412
- "Each indicator is assigned a traffic-light status: "
413
- "<b><font color='{}'>GREEN</font></b> indicates conditions within the normal range, "
414
- "<b><font color='{}'>AMBER</font></b> indicates elevated concern requiring monitoring, "
415
- "and <b><font color='{}'>RED</font></b> indicates a critical situation requiring immediate attention. "
416
- "Trend arrows indicate the direction of change over the analysis period. "
417
- "Confidence levels reflect data quality and coverage."
418
- ).format(_GREEN_HEX, _AMBER_HEX, _RED_HEX)
419
- story.append(Paragraph(how_to, styles["body"]))
420
- story.append(Spacer(1, 4 * mm))
421
-
422
- # ------------------------------------------------------------------ #
423
- # Executive Summary #
424
- # ------------------------------------------------------------------ #
425
  red_count = sum(1 for r in results if r.status == StatusLevel.RED)
426
  amber_count = sum(1 for r in results if r.status == StatusLevel.AMBER)
427
  green_count = sum(1 for r in results if r.status == StatusLevel.GREEN)
428
  total = len(results)
429
-
430
- exec_summary_text = (
431
  f"This report covers <b>{total}</b> indicator(s) for <b>{aoi.name}</b> "
432
  f"over the period {time_range.start} to {time_range.end}. "
433
- f"Of these, <b><font color='{_RED_HEX}'>{red_count} indicator(s)</font></b> are at "
434
- f"<b><font color='{_RED_HEX}'>RED</font></b> status (critical concern), "
435
- f"<b><font color='{_AMBER_HEX}'>{amber_count} indicator(s)</font></b> are at "
436
- f"<b><font color='{_AMBER_HEX}'>AMBER</font></b> status (elevated concern), and "
437
- f"<b><font color='{_GREEN_HEX}'>{green_count} indicator(s)</font></b> are at "
438
- f"<b><font color='{_GREEN_HEX}'>GREEN</font></b> status (within normal range)."
439
  )
440
- story.append(Paragraph("Executive Summary", styles["section_heading"]))
441
- story.append(Paragraph(exec_summary_text, styles["body"]))
442
- story.append(Spacer(1, 6 * mm))
443
-
444
- # ------------------------------------------------------------------ #
445
- # Summary Map #
446
- # ------------------------------------------------------------------ #
447
- if summary_map_path and os.path.exists(summary_map_path):
448
- from reportlab.platypus import Image
449
- img = Image(summary_map_path, width=14 * cm, height=10.5 * cm)
450
- img.hAlign = "CENTER"
451
- story.append(img)
452
- story.append(Spacer(1, 4 * mm))
453
-
454
- # ------------------------------------------------------------------ #
455
- # Indicator Results #
456
- # ------------------------------------------------------------------ #
457
- story.append(Paragraph("Indicator Results", styles["section_heading"]))
458
- story.append(Spacer(1, 2 * mm))
459
 
460
- for result in results:
461
- indicator_label = _indicator_label(result.indicator_id)
462
- block = [Paragraph(indicator_label, styles["section_heading"])]
463
- map_path = (indicator_map_paths or {}).get(result.indicator_id, "")
464
- block += _indicator_block(result, styles, map_path=map_path)
465
- story.append(KeepTogether(block))
466
-
467
- # ------------------------------------------------------------------ #
468
- # Status Summary Table #
469
- # ------------------------------------------------------------------ #
470
- story.append(Paragraph("Status Summary", styles["section_heading"]))
471
- story.append(Spacer(1, 2 * mm))
472
 
473
- table_header = [
 
474
  Paragraph("<b>Indicator</b>", styles["body"]),
475
  Paragraph("<b>Status</b>", styles["body"]),
476
  Paragraph("<b>Trend</b>", styles["body"]),
477
  Paragraph("<b>Confidence</b>", styles["body"]),
 
478
  ]
479
- table_data = [table_header]
480
  for result in results:
481
  label = _indicator_label(result.indicator_id)
482
- status_color = STATUS_COLORS[result.status]
483
  status_cell = Paragraph(
484
  f'<font color="white"><b>{STATUS_LABELS[result.status]}</b></font>',
485
  ParagraphStyle(
486
- "tbl_badge",
487
  fontName="Helvetica-Bold",
488
- fontSize=8,
489
  textColor=colors.white,
490
  alignment=TA_CENTER,
491
  ),
492
  )
493
- table_data.append([
494
- Paragraph(label, styles["body"]),
495
  status_cell,
496
- Paragraph(result.trend.value.capitalize(), styles["body"]),
497
- Paragraph(result.confidence.value.capitalize(), styles["body"]),
 
498
  ])
499
 
500
- col_w = (PAGE_W - 2 * MARGIN) / 4
501
- summary_table = Table(table_data, colWidths=[col_w * 1.4, col_w * 0.7, col_w * 0.9, col_w])
502
- ts = TableStyle([
 
 
 
 
 
 
 
 
 
503
  ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8E6E0")),
504
  ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor(_SHELL_HEX)]),
505
  ("GRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#D8D5CF")),
506
- ("TOPPADDING", (0, 0), (-1, -1), 4),
507
- ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
508
- ("LEFTPADDING", (0, 0), (-1, -1), 6),
509
- ("RIGHTPADDING", (0, 0), (-1, -1), 6),
510
  ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
511
  ("ALIGN", (1, 1), (1, -1), "CENTER"),
512
  ])
513
- # Apply status cell backgrounds dynamically
514
  for row_idx, result in enumerate(results, start=1):
515
- ts.add("BACKGROUND", (1, row_idx), (1, row_idx), STATUS_COLORS[result.status])
516
- summary_table.setStyle(ts)
517
- story.append(summary_table)
 
518
  story.append(Spacer(1, 6 * mm))
 
 
 
 
 
 
 
 
519
 
520
- # ------------------------------------------------------------------ #
521
- # Data Sources & Methodology #
522
- # ------------------------------------------------------------------ #
523
- story.append(Paragraph("Data Sources &amp; Methodology", styles["section_heading"]))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
524
  for result in results:
525
  indicator_label = _indicator_label(result.indicator_id)
526
  story.append(
@@ -529,22 +525,33 @@ def generate_pdf_report(
529
  styles["body"],
530
  )
531
  )
532
- story.append(Spacer(1, 6 * mm))
533
 
534
- # ------------------------------------------------------------------ #
535
- # Disclaimer #
536
- # ------------------------------------------------------------------ #
 
 
 
 
 
 
 
 
 
 
 
 
 
 
537
  story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#D8D5CF")))
538
  story.append(Spacer(1, 3 * mm))
539
  disclaimer = (
540
  "This report has been generated automatically by MERLx Aperture using satellite "
541
  "remote sensing data. Results are intended to support humanitarian situation analysis "
542
  "and should be interpreted alongside ground-truth information and expert judgement. "
543
- "MERLx Aperture makes no warranty as to the accuracy or completeness of the data presented. "
544
- "All indicators are based on open-source satellite imagery and publicly available "
545
- "geospatial datasets. Temporal coverage, cloud contamination, and sensor resolution "
546
- "may affect the reliability of individual indicators. Users are encouraged to review "
547
- "the methodology and limitations sections before drawing operational conclusions."
548
  )
549
  story.append(Paragraph("Disclaimer", styles["section_heading"]))
550
  story.append(Paragraph(disclaimer, styles["body_muted"]))
 
160
  return t
161
 
162
 
163
+ def _indicator_block(
164
+ result: IndicatorResult,
165
+ styles: dict,
166
+ map_path: str = "",
167
+ chart_path: str = "",
168
+ ) -> list:
169
  """Build the flowables for a single indicator section."""
170
+ from app.outputs.narrative import get_interpretation
171
+ from reportlab.platypus import Image
172
+
173
  elements = []
174
 
175
  # Badge + headline row
 
198
  ))
199
  elements.append(Spacer(1, 2 * mm))
200
 
201
+ # Map and chart side by side (if both exist), or single image
202
+ map_exists = map_path and os.path.exists(map_path)
203
+ chart_exists = chart_path and os.path.exists(chart_path)
 
 
 
 
204
 
205
+ if map_exists and chart_exists:
206
+ map_img = Image(map_path, width=8 * cm, height=6 * cm)
207
+ chart_img = Image(chart_path, width=8 * cm, height=6 * cm)
208
+ img_table = Table(
209
+ [[map_img, chart_img]],
210
+ colWidths=[8.5 * cm, 8.5 * cm],
211
+ )
212
+ img_table.setStyle(TableStyle([
213
+ ("ALIGN", (0, 0), (-1, -1), "CENTER"),
214
+ ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
215
+ ("LEFTPADDING", (0, 0), (-1, -1), 0),
216
+ ("RIGHTPADDING", (0, 0), (-1, -1), 0),
217
+ ]))
218
+ elements.append(img_table)
219
+ elements.append(Spacer(1, 2 * mm))
220
+ elif map_exists:
221
  img = Image(map_path, width=12 * cm, height=9 * cm)
222
  img.hAlign = "CENTER"
223
  elements.append(img)
224
  elements.append(Spacer(1, 2 * mm))
225
+ elif chart_exists:
226
+ img = Image(chart_path, width=12 * cm, height=9 * cm)
227
+ img.hAlign = "CENTER"
228
+ elements.append(img)
229
+ elements.append(Spacer(1, 2 * mm))
230
 
231
+ # What the data shows
232
+ elements.append(Paragraph("<b>What the data shows</b>", styles["body_muted"]))
233
  elements.append(Paragraph(result.summary, styles["body"]))
234
 
235
+ # What this means
236
+ interpretation = get_interpretation(result.indicator_id, result.status)
237
+ elements.append(Paragraph("<b>What this means</b>", styles["body_muted"]))
238
+ elements.append(Paragraph(interpretation, styles["body"]))
239
+
240
+ # Data quality line
241
+ data_source_label = result.data_source or "unknown"
242
+ quality_line = (
243
+ f"Confidence: <b>{result.confidence.value.capitalize()}</b> &nbsp;|&nbsp; "
244
+ f"Trend: <b>{result.trend.value.capitalize()}</b> &nbsp;|&nbsp; "
245
+ f"Source: <b>{data_source_label}</b>"
246
+ )
247
+ elements.append(Paragraph(quality_line, styles["body_muted"]))
248
 
249
  elements.append(Spacer(1, 4 * mm))
250
  elements.append(
 
254
  return elements
255
 
256
 
257
+ def _detect_data_sources(results: Sequence[IndicatorResult]) -> str:
258
+ """Scan methodology text to auto-detect known satellite data sources."""
259
+ known_sources = ["Sentinel-2", "Sentinel-1", "FIRMS", "CHIRPS", "MODIS", "Landsat", "VIIRS"]
260
+ found: list[str] = []
261
+ combined_text = " ".join(r.methodology for r in results)
262
+ for src in known_sources:
263
+ if src.lower() in combined_text.lower() and src not in found:
264
+ found.append(src)
265
+ if not found:
266
+ return "Open-source satellite imagery"
267
+ return ", ".join(found)
268
+
269
+
270
  def generate_pdf_report(
271
  *,
272
  aoi: AOI,
 
291
  output_path:
292
  Absolute path where the PDF should be saved.
293
  """
294
+ from app.outputs.narrative import generate_narrative
295
+
296
  styles = _build_styles()
297
  PAGE_W, PAGE_H = A4
298
  MARGIN = 2 * cm
 
328
  )
329
  doc.pageBackgrounds = [colors.white]
330
 
 
 
 
 
 
331
  from datetime import datetime as _dt, timezone as _tz
332
  generated_at = _dt.now(_tz.utc).strftime("%Y-%m-%d %H:%M UTC")
333
 
334
+ # Output directory for chart path auto-detection
335
+ output_dir = os.path.dirname(output_path)
336
+
337
+ story = []
338
+
339
+ # ================================================================== #
340
+ # SECTION 1: The Place #
341
+ # ================================================================== #
342
  story.append(Paragraph("MERLx Aperture — Situation Report", styles["title"]))
343
  story.append(Paragraph(aoi.name, styles["subtitle"]))
344
+ story.append(Spacer(1, 2 * mm))
345
+
346
+ # Overview map (full width)
347
+ if overview_map_path and os.path.exists(overview_map_path):
348
+ from reportlab.platypus import Image
349
+ img = Image(overview_map_path, width=14 * cm, height=10.5 * cm)
350
+ img.hAlign = "CENTER"
351
+ story.append(img)
352
+ story.append(Spacer(1, 3 * mm))
353
+
354
+ # AOI metadata
355
  story.append(
356
  Paragraph(
357
  f"Analysis period: {time_range.start} \u2013 {time_range.end}",
 
366
  styles["body_muted"],
367
  )
368
  )
369
+
370
+ # Auto-detected data sources
371
+ data_sources_str = _detect_data_sources(results)
372
+ story.append(
373
+ Paragraph(
374
+ f"Data sources: {data_sources_str}",
375
+ styles["body_muted"],
376
+ )
377
+ )
378
+
379
  story.append(Spacer(1, 4 * mm))
380
  story.append(HRFlowable(width="100%", thickness=1, color=INK_MUTED))
381
  story.append(Spacer(1, 6 * mm))
382
 
383
+ # ================================================================== #
384
+ # SECTION 2: Situation Assessment #
385
+ # ================================================================== #
386
+ story.append(Paragraph("Situation Assessment", styles["section_heading"]))
387
+ story.append(Spacer(1, 2 * mm))
 
 
 
 
 
 
 
 
388
 
389
+ # Composite badge + headline (if overview_score provided)
390
+ if overview_score:
391
  composite_status_enum = {
392
  "GREEN": StatusLevel.GREEN,
393
  "AMBER": StatusLevel.AMBER,
 
412
  story.append(row)
413
  story.append(Spacer(1, 4 * mm))
414
 
415
+ # Cross-indicator narrative
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
  red_count = sum(1 for r in results if r.status == StatusLevel.RED)
417
  amber_count = sum(1 for r in results if r.status == StatusLevel.AMBER)
418
  green_count = sum(1 for r in results if r.status == StatusLevel.GREEN)
419
  total = len(results)
420
+ count_line = (
 
421
  f"This report covers <b>{total}</b> indicator(s) for <b>{aoi.name}</b> "
422
  f"over the period {time_range.start} to {time_range.end}. "
423
+ f"<b><font color='{_RED_HEX}'>{red_count}</font></b> at RED, "
424
+ f"<b><font color='{_AMBER_HEX}'>{amber_count}</font></b> at AMBER, "
425
+ f"<b><font color='{_GREEN_HEX}'>{green_count}</font></b> at GREEN."
 
 
 
426
  )
427
+ story.append(Paragraph(count_line, styles["body"]))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
 
429
+ narrative_text = generate_narrative(results)
430
+ story.append(Paragraph(narrative_text, styles["body"]))
431
+ story.append(Spacer(1, 4 * mm))
 
 
 
 
 
 
 
 
 
432
 
433
+ # Compact summary table
434
+ summary_header = [
435
  Paragraph("<b>Indicator</b>", styles["body"]),
436
  Paragraph("<b>Status</b>", styles["body"]),
437
  Paragraph("<b>Trend</b>", styles["body"]),
438
  Paragraph("<b>Confidence</b>", styles["body"]),
439
+ Paragraph("<b>Headline</b>", styles["body"]),
440
  ]
441
+ summary_rows = [summary_header]
442
  for result in results:
443
  label = _indicator_label(result.indicator_id)
 
444
  status_cell = Paragraph(
445
  f'<font color="white"><b>{STATUS_LABELS[result.status]}</b></font>',
446
  ParagraphStyle(
447
+ "ov_badge",
448
  fontName="Helvetica-Bold",
449
+ fontSize=7,
450
  textColor=colors.white,
451
  alignment=TA_CENTER,
452
  ),
453
  )
454
+ summary_rows.append([
455
+ Paragraph(label, styles["body_muted"]),
456
  status_cell,
457
+ Paragraph(result.trend.value.capitalize(), styles["body_muted"]),
458
+ Paragraph(result.confidence.value.capitalize(), styles["body_muted"]),
459
+ Paragraph(result.headline[:80], styles["body_muted"]),
460
  ])
461
 
462
+ ov_col_w = PAGE_W - 2 * MARGIN
463
+ ov_table = Table(
464
+ summary_rows,
465
+ colWidths=[
466
+ ov_col_w * 0.15,
467
+ ov_col_w * 0.10,
468
+ ov_col_w * 0.12,
469
+ ov_col_w * 0.13,
470
+ ov_col_w * 0.50,
471
+ ],
472
+ )
473
+ ov_ts = TableStyle([
474
  ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#E8E6E0")),
475
  ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor(_SHELL_HEX)]),
476
  ("GRID", (0, 0), (-1, -1), 0.3, colors.HexColor("#D8D5CF")),
477
+ ("TOPPADDING", (0, 0), (-1, -1), 3),
478
+ ("BOTTOMPADDING", (0, 0), (-1, -1), 3),
479
+ ("LEFTPADDING", (0, 0), (-1, -1), 4),
480
+ ("RIGHTPADDING", (0, 0), (-1, -1), 4),
481
  ("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
482
  ("ALIGN", (1, 1), (1, -1), "CENTER"),
483
  ])
 
484
  for row_idx, result in enumerate(results, start=1):
485
+ ov_ts.add("BACKGROUND", (1, row_idx), (1, row_idx), STATUS_COLORS[result.status])
486
+ ov_table.setStyle(ov_ts)
487
+ story.append(ov_table)
488
+
489
  story.append(Spacer(1, 6 * mm))
490
+ story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#D8D5CF")))
491
+ story.append(Spacer(1, 4 * mm))
492
+
493
+ # ================================================================== #
494
+ # SECTION 3: Indicator Deep Dives #
495
+ # ================================================================== #
496
+ story.append(Paragraph("Indicator Detail", styles["section_heading"]))
497
+ story.append(Spacer(1, 2 * mm))
498
 
499
+ for result in results:
500
+ indicator_label = _indicator_label(result.indicator_id)
501
+ map_path = (indicator_map_paths or {}).get(result.indicator_id, "")
502
+
503
+ # Auto-detect chart path from output directory
504
+ chart_path = os.path.join(output_dir, f"{result.indicator_id}_chart.png")
505
+ if not os.path.exists(chart_path):
506
+ chart_path = ""
507
+
508
+ block = [Paragraph(indicator_label, styles["section_heading"])]
509
+ block += _indicator_block(result, styles, map_path=map_path, chart_path=chart_path)
510
+ story.append(KeepTogether(block))
511
+
512
+ # ================================================================== #
513
+ # SECTION 4: Technical Annex #
514
+ # ================================================================== #
515
+ story.append(Paragraph("Technical Annex", styles["section_heading"]))
516
+ story.append(Spacer(1, 2 * mm))
517
+
518
+ # Methodology subsection
519
+ story.append(Paragraph("Methodology", styles["section_heading"]))
520
  for result in results:
521
  indicator_label = _indicator_label(result.indicator_id)
522
  story.append(
 
525
  styles["body"],
526
  )
527
  )
528
+ story.append(Spacer(1, 4 * mm))
529
 
530
+ # Limitations subsection — deduplicated across all indicators
531
+ all_limitations: list[str] = []
532
+ seen: set[str] = set()
533
+ for result in results:
534
+ for lim in (result.limitations or []):
535
+ normalized = lim.strip().rstrip(".")
536
+ if normalized.lower() not in seen:
537
+ seen.add(normalized.lower())
538
+ all_limitations.append(lim)
539
+
540
+ if all_limitations:
541
+ story.append(Paragraph("Limitations", styles["section_heading"]))
542
+ for lim in all_limitations:
543
+ story.append(Paragraph(f"\u2022 {lim}", styles["limitation"]))
544
+ story.append(Spacer(1, 4 * mm))
545
+
546
+ # Disclaimer
547
  story.append(HRFlowable(width="100%", thickness=0.5, color=colors.HexColor("#D8D5CF")))
548
  story.append(Spacer(1, 3 * mm))
549
  disclaimer = (
550
  "This report has been generated automatically by MERLx Aperture using satellite "
551
  "remote sensing data. Results are intended to support humanitarian situation analysis "
552
  "and should be interpreted alongside ground-truth information and expert judgement. "
553
+ "Temporal coverage, cloud contamination, and sensor resolution may affect the "
554
+ "reliability of individual indicators."
 
 
 
555
  )
556
  story.append(Paragraph("Disclaimer", styles["section_heading"]))
557
  story.append(Paragraph(disclaimer, styles["body_muted"]))