Files

254 lines
10 KiB
Python
Raw Permalink Normal View History

2026-04-20 15:23:18 +12:00
"""
ppt_builder.py — PPTX board-pack generation for the SHEQ Analysis Tool.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime
from pptx import Presentation
from pptx.dml.color import RGBColor
from pptx.enum.shapes import MSO_AUTO_SHAPE_TYPE
from pptx.enum.text import PP_ALIGN
from pptx.util import Inches, Pt
from analysis_engine import AnalysisResults
log = logging.getLogger(__name__)
DEEP_BLUE = RGBColor(0x0B, 0x32, 0x54)
SKY_BLUE = RGBColor(0x13, 0xB5, 0xEA)
DARK_GREEN = RGBColor(0x00, 0x6E, 0x47)
AMBER = RGBColor(0xD9, 0x77, 0x06)
RED = RGBColor(0xDC, 0x26, 0x26)
GREY = RGBColor(0x64, 0x74, 0x8B)
OFF_WHITE = RGBColor(0xF0, 0xF5, 0xFA)
WHITE = RGBColor(0xFF, 0xFF, 0xFF)
def _slide_title(slide, title: str, subtitle: str | None = None) -> None:
tx = slide.shapes.add_textbox(Inches(0.45), Inches(0.25), Inches(12.1), Inches(0.8))
tf = tx.text_frame
p = tf.paragraphs[0]
r = p.add_run()
r.text = title
r.font.name = "Source Sans Pro"
r.font.size = Pt(24)
r.font.bold = True
r.font.color.rgb = DEEP_BLUE
if subtitle:
p2 = tf.add_paragraph()
r2 = p2.add_run()
r2.text = subtitle
r2.font.name = "Source Sans Pro"
r2.font.size = Pt(11)
r2.font.color.rgb = GREY
def _add_banner(slide, title: str, subtitle: str) -> None:
shape = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.RECTANGLE, Inches(0), Inches(0), Inches(13.33), Inches(1.3))
shape.fill.solid()
shape.fill.fore_color.rgb = DEEP_BLUE
shape.line.fill.background()
tf = shape.text_frame
tf.clear()
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.LEFT
r = p.add_run()
r.text = title
r.font.name = "Source Sans Pro"
r.font.size = Pt(26)
r.font.bold = True
r.font.color.rgb = WHITE
p2 = tf.add_paragraph()
r2 = p2.add_run()
r2.text = subtitle
r2.font.name = "Source Sans Pro"
r2.font.size = Pt(12)
r2.font.color.rgb = RGBColor(0xD7, 0xF2, 0xFF)
def _add_text_card(slide, left, top, width, height, title: str, body: str, fill=OFF_WHITE, accent=SKY_BLUE) -> None:
shape = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE, left, top, width, height)
shape.fill.solid()
shape.fill.fore_color.rgb = fill
shape.line.color.rgb = WHITE
tf = shape.text_frame
tf.clear()
p = tf.paragraphs[0]
r = p.add_run()
r.text = title
r.font.name = "Source Sans Pro"
r.font.size = Pt(11)
r.font.bold = True
r.font.color.rgb = accent
p2 = tf.add_paragraph()
r2 = p2.add_run()
r2.text = body
r2.font.name = "Source Sans Pro"
r2.font.size = Pt(10)
r2.font.color.rgb = DEEP_BLUE
def _add_metric_card(slide, left, top, width, height, label: str, value: str, fill=OFF_WHITE) -> None:
shape = slide.shapes.add_shape(MSO_AUTO_SHAPE_TYPE.ROUNDED_RECTANGLE, left, top, width, height)
shape.fill.solid()
shape.fill.fore_color.rgb = fill
shape.line.color.rgb = WHITE
tf = shape.text_frame
tf.clear()
p = tf.paragraphs[0]
p.alignment = PP_ALIGN.CENTER
r = p.add_run()
r.text = label
r.font.name = "Source Sans Pro"
r.font.size = Pt(9)
r.font.color.rgb = GREY
p2 = tf.add_paragraph()
p2.alignment = PP_ALIGN.CENTER
r2 = p2.add_run()
r2.text = value
r2.font.name = "Source Sans Pro"
r2.font.size = Pt(18)
r2.font.bold = True
r2.font.color.rgb = DEEP_BLUE
def _add_bullets(slide, left, top, width, height, title: str, items: list[str], accent=DEEP_BLUE) -> None:
tx = slide.shapes.add_textbox(left, top, width, height)
tf = tx.text_frame
tf.clear()
p = tf.paragraphs[0]
r = p.add_run()
r.text = title
r.font.name = "Source Sans Pro"
r.font.size = Pt(15)
r.font.bold = True
r.font.color.rgb = accent
for item in items:
para = tf.add_paragraph()
para.level = 0
para.text = item
para.font.name = "Source Sans Pro"
para.font.size = Pt(11)
para.font.color.rgb = DEEP_BLUE
para.bullet = True
def _add_chart(slide, image_path: str | None, left, top, width, height=None) -> None:
if image_path and os.path.exists(image_path):
if height is None:
slide.shapes.add_picture(image_path, left, top, width=width)
else:
slide.shapes.add_picture(image_path, left, top, width=width, height=height)
def build_presentation(results: AnalysisResults, output_dir: str) -> str:
os.makedirs(output_dir, exist_ok=True)
prs = Presentation()
prs.slide_width = Inches(13.333)
prs.slide_height = Inches(7.5)
blank = prs.slide_layouts[6]
# Title
slide = prs.slides.add_slide(blank)
_add_banner(slide, "SHEQ Safety Performance Board Pack", "Safety Energy, event hotspots, and leadership action priorities")
dq = results.data_quality
_add_text_card(
slide, Inches(0.6), Inches(1.6), Inches(12.0), Inches(1.0),
"Scope",
f"Events: {dq.get('events', {}).get('date_from', 'N/A')} to {dq.get('events', {}).get('date_to', 'N/A')} | "
f"Safety Energy: {dq.get('safety_energy', {}).get('date_from', 'N/A')} to {dq.get('safety_energy', {}).get('date_to', 'N/A')}",
)
ev = results.events_summary
lead = results.leading_summary
trends = results.trends
metric_y = Inches(3.0)
card_w = Inches(2.85)
gap = Inches(0.2)
metrics = [
("Events", str(ev.get("total", 0))),
("Moderate+ Events", str(ev.get("serious_count", 0))),
("MV Events", str(ev.get("motor_vehicle", {}).get("count", 0))),
("CCC Avg Quality", f"{trends.get('activity_insights', {}).get('CCC', {}).get('avg_quality', 0):.1f}"),
]
for i, (label, value) in enumerate(metrics):
_add_metric_card(slide, Inches(0.6) + i * (card_w + gap), metric_y, card_w, Inches(1.3), label, value)
_add_text_card(slide, Inches(0.6), Inches(4.9), Inches(12.0), Inches(1.2), "Generated", datetime.now().strftime("%d %B %Y"))
# Executive summary
slide = prs.slides.add_slide(blank)
_slide_title(slide, "Executive Summary", "What leaders should know right now")
_add_chart(slide, results.charts.get("quality_mix"), Inches(0.55), Inches(1.1), Inches(5.9))
_add_chart(slide, results.charts.get("project_quadrant"), Inches(6.75), Inches(1.1), Inches(5.95))
_add_bullets(slide, Inches(0.6), Inches(4.55), Inches(6.0), Inches(2.2), "Key Messages", trends.get("executive_summary", [])[:4], accent=SKY_BLUE)
_add_bullets(slide, Inches(6.8), Inches(4.55), Inches(5.9), Inches(2.2), "Priority Actions", results.recommendations[:4], accent=RED)
# Events hotspots
slide = prs.slides.add_slide(blank)
_slide_title(slide, "Event Hotspots", "Where event burden and serious consequences are concentrated")
_add_chart(slide, results.charts.get("serious_hotspots"), Inches(0.55), Inches(1.05), Inches(6.0))
_add_chart(slide, results.charts.get("events_monthly"), Inches(6.75), Inches(1.05), Inches(5.95))
event_notes = []
if ev.get("serious_projects"):
top_p = next(iter(ev["serious_projects"].items()))
event_notes.append(f"Highest serious-event project: {top_p[0]} ({top_p[1]} serious events)")
if ev.get("serious_time_buckets"):
top_t = next(iter(ev["serious_time_buckets"].items()))
event_notes.append(f"Most common serious-event timing: {top_t[0]} ({top_t[1]} events)")
if ev.get("motor_vehicle", {}).get("count", 0):
event_notes.append(
f"Motor vehicle events: {ev['motor_vehicle']['count']} total, "
f"{ev['motor_vehicle'].get('serious_count', 0)} moderate+"
)
_add_bullets(slide, Inches(0.6), Inches(4.75), Inches(12.0), Inches(1.9), "Leadership Focus", event_notes, accent=SKY_BLUE)
# Leading activity quality
slide = prs.slides.add_slide(blank)
_slide_title(slide, "Leading Activity Quality", "Whether LLC, CCC, and OCC records look meaningful and actionable")
_add_chart(slide, results.charts.get("quality_trend"), Inches(0.55), Inches(1.0), Inches(6.0))
_add_chart(slide, results.charts.get("quality_mix"), Inches(6.75), Inches(1.0), Inches(5.95))
quality_notes = []
for atype in ["CCC", "OCC", "LLC"]:
insight = trends.get("activity_insights", {}).get(atype, {})
if insight:
quality_notes.append(
f"{atype}: quality {insight.get('avg_quality', 0):.1f}/100, shallow {insight.get('shallow_pct', 0):.1f}%, "
f"follow-up {insight.get('follow_up_pct', 0):.1f}%"
)
_add_bullets(slide, Inches(0.6), Inches(4.85), Inches(12.0), Inches(1.7), "Quality Readout", quality_notes, accent=SKY_BLUE)
# Projects and locations
slide = prs.slides.add_slide(blank)
_slide_title(slide, "Projects and Locations", "Which areas appear strongest and which need direct intervention")
_add_chart(slide, results.charts.get("project_quadrant"), Inches(0.55), Inches(1.05), Inches(6.0))
_add_chart(slide, results.charts.get("low_value_units"), Inches(6.75), Inches(1.05), Inches(5.95))
proj_watch = results.se_events_rel.get("project_comparison", {}).get("watch", [])[:3]
loc_watch = results.se_events_rel.get("location_comparison", {}).get("watch", [])[:3]
watch_items = [
f"Project watch: {r.get('project')} | events {r.get('events')} | serious {r.get('serious_events')}"
for r in proj_watch
] + [
f"Location watch: {r.get('location')} | events {r.get('events')} | serious {r.get('serious_events')}"
for r in loc_watch
]
_add_bullets(slide, Inches(0.6), Inches(4.85), Inches(12.0), Inches(1.7), "Watchlist", watch_items[:6], accent=RED)
# Recommendations
slide = prs.slides.add_slide(blank)
_slide_title(slide, "Recommended Actions", "Executive actions generated from the analysis")
left_items = results.recommendations[:5]
right_items = results.recommendations[5:10]
_add_bullets(slide, Inches(0.6), Inches(1.15), Inches(5.8), Inches(5.7), "Immediate Priorities", left_items, accent=RED)
_add_bullets(slide, Inches(6.8), Inches(1.15), Inches(5.8), Inches(5.7), "Next Priorities", right_items, accent=SKY_BLUE)
output_path = os.path.join(
output_dir,
f"SHEQ_Safety_Performance_{datetime.now().strftime('%Y%m%d_%H%M')}.pptx",
)
prs.save(output_path)
log.info("PPTX saved to %s", output_path)
return output_path