254 lines
10 KiB
Python
254 lines
10 KiB
Python
"""
|
|
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
|