adeo-maturity-tool/generate_summary.py
Phil Dore 1fd3dc00ae Initial commit — ADEO Content Maturity Assessment Tool
Multi-client maturity dashboard (Express/vanilla JS, port 3102).
ADEO client: 8 markets, 7 pillars, 59 questions.
Includes data converter, universal PDF summary generator, and
new-client wizard for adding future clients.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-28 16:15:56 +01:00

778 lines
33 KiB
Python

#!/usr/bin/env python3
"""
ADEO Content Maturity Assessment — Universal Summary PDF Generator
Usage: python3 generate_summary.py <MARKET_ID>
Available markets:
BU_LM_FRANCE BU_LM_ITALY BU_LM_POLAND BU_LM_SPAIN
BU_LM_PORTUGAL BU_M_SPAIN BU_M_ITALY
"""
import re
import sys
import shutil
from pathlib import Path
# ─────────────────────────────────────────────
# PATHS & CONSTANTS
# ─────────────────────────────────────────────
BOX = Path("/Users/phildore/Library/CloudStorage/Box-Box/ADEO_EXTERNAL_SHARE")
DESKTOP = Path("/Users/phildore/Desktop/Adeo _Asset_Production_Summary")
PILLAR_ORDER = [
"OMNICHANNEL",
"CLIENT CENTRICITY",
"MEASUREMENT",
"TECH CAPABILITIES",
"AUTOMATION & INDUSTRIALIZATION",
"INNOVATION",
"ORGANISATION",
]
SCORE_LEVEL = {1: "Learner", 2: "Intermediate", 3: "Master", 4: "Expert"}
SCORE_COLOR = {1: "#C62828", 2: "#E65100", 3: "#2E7D32", 4: "#1B5E20"}
ACCENT = "#00CCDF"
NAVY = "#1A2B3C"
WHITE = "#FFFFFF"
BODY_GRAY = "#222222"
# ─────────────────────────────────────────────
# MARKET CONFIGURATIONS
# ─────────────────────────────────────────────
MARKETS = {
"BU_LM_FRANCE": {
"label": "Leroy Merlin France",
"short": "LM France",
"q_dir": BOX / "BU_LM_FRANCE/05_MATURITY/00_MASTER",
"out_dir": BOX / "BU_LM_FRANCE/05_MATURITY/01_OUTPUTS/PDF",
"desktop_dir": DESKTOP / "LM France ",
"filename": "BU_LM_FRANCE_Maturity_Assessment_Summary_v3.pdf",
"footer": "Leroy Merlin France · Content Maturity Assessment · April 2026 · Confidential",
},
"BU_LM_ITALY": {
"label": "Leroy Merlin Italy",
"short": "LM Italy",
"q_dir": BOX / "BU_LM_ITALY/06_MATURITY_GRID/00_MASTER",
"out_dir": BOX / "BU_LM_ITALY/06_MATURITY_GRID/02_OUTPUTS/PDF",
"desktop_dir": DESKTOP / "LM Italy ",
"filename": "BU_LM_ITALY_Maturity_Assessment_Summary_v2.pdf",
"footer": "Leroy Merlin Italy · Content Maturity Assessment · April 2026 · Confidential",
},
"BU_LM_POLAND": {
"label": "Leroy Merlin Poland",
"short": "LM Poland",
"q_dir": BOX / "BU_LM_POLAND/05_MATURITY/00_MASTER",
"out_dir": BOX / "BU_LM_POLAND/05_MATURITY/01_OUTPUTS/PDF",
"desktop_dir": DESKTOP / "LM Poland ",
"filename": "BU_LM_POLAND_Maturity_Assessment_Summary_v3.pdf",
"footer": "Leroy Merlin Poland · Content Maturity Assessment · April 2026 · Confidential",
},
"BU_LM_SPAIN": {
"label": "Leroy Merlin Spain",
"short": "LM Spain",
"q_dir": BOX / "BU_LM_SPAIN/06_MATURITY/02_VERSION/V4",
"out_dir": BOX / "BU_LM_SPAIN/06_MATURITY/01_OUTPUT/PDF/V4",
"desktop_dir": DESKTOP / "M Spain ",
"filename": "BU_LM_SPAIN_Maturity_Assessment_Summary_V5.pdf",
"footer": "Leroy Merlin Spain · Content Maturity Assessment · April 2026 · Confidential",
},
"BU_LM_PORTUGAL": {
"label": "Leroy Merlin Portugal",
"short": "LM Portugal",
"q_dir": BOX / "BU_LM_PORTUGAL copy/05_MATURITY/00_MASTER",
"out_dir": BOX / "BU_LM_PORTUGAL copy/05_MATURITY/02_OUTPUTS/PDF/Round_3",
"desktop_dir": DESKTOP / "LM Portugal ",
"filename": "BU_LM_PORTUGAL_Maturity_Assessment_Summary_v3.pdf",
"footer": "Leroy Merlin Portugal · Content Maturity Assessment · April 2026 · Confidential",
},
"BU_M_SPAIN": {
"label": "Obramat Spain",
"short": "Obramat Spain",
"q_dir": BOX / "BU_M_SPAIN/06_QUESTIONS/MASTER",
"out_dir": BOX / "BU_M_SPAIN/08_ASSESSMENT/02_Round_2/PDF",
"desktop_dir": DESKTOP / "M Spain ",
"filename": "BU_M_SPAIN_Maturity_Assessment_Summary_v3.pdf",
"footer": "Obramat Spain · Content Maturity Assessment · April 2026 · Confidential",
},
"BU_M_ITALY": {
"label": "Tecnomat Italy",
"short": "Tecnomat Italy",
"q_dir": BOX / "BU_M_ITALY",
"out_dir": BOX / "BU_M_ITALY/06_ASSESSMENT/02_Round_2/PDF",
"desktop_dir": DESKTOP / "M Italy ",
"filename": "BU_M_ITALY_Maturity_Assessment_Summary_v3.pdf",
"footer": "Tecnomat Italy · Content Maturity Assessment · April 2026 · Confidential",
},
}
# ─────────────────────────────────────────────
# FLEXIBLE SECTION PARSERS
# ─────────────────────────────────────────────
def parse_numbered_bullets(block: str) -> list[tuple[str, str]]:
"""Extract `1. **Title:** body` style bullets from a text block."""
bullets = re.findall(
r"^\d+\.\s+\*\*(.+?):\*\*\s*(.+?)(?=\n\d+\.|\Z)",
block,
re.DOTALL | re.MULTILINE,
)
return [(t.strip(), b.strip()) for t, b in bullets]
def extract_strengths(text: str) -> list[tuple[str, str]]:
"""Find Strengths bullets from any known section format."""
# Top-level ## Strengths
m = re.search(r"##\s+Strengths\s*\n(.*?)(?=\n##\s|\Z)", text, re.DOTALL)
if m:
bullets = parse_numbered_bullets(m.group(1))
if bullets:
return bullets
# Nested ### Strengths (under a custom analysis section)
m = re.search(r"###\s+Strengths\s*\n(.*?)(?=\n##\s|\n###\s|\Z)", text, re.DOTALL)
if m:
bullets = parse_numbered_bullets(m.group(1))
if bullets:
return bullets
# ## Main Analysis used in place of Strengths (LM Spain V4 style)
m = re.search(r"##\s+Main Analysis\s*\n(.*?)(?=\n##\s|\Z)", text, re.DOTALL)
if m:
bullets = parse_numbered_bullets(m.group(1))
if bullets:
return bullets
# If no numbered bullets, grab ### subheadings as bullet items
subs = re.findall(
r"###\s+(.+?)\n(.*?)(?=\n###\s|\n##\s|\Z)",
m.group(1),
re.DOTALL,
)
result = []
for title, body in subs:
body_clean = body.strip()
if body_clean:
result.append((title.strip(), body_clean))
if result:
return result
return []
def extract_gaps(text: str) -> list[tuple[str, str]]:
"""Find Gaps bullets from any known section format."""
patterns = [
r"##\s+Gaps\s+or\s+Minor\s+Gaps\s*\n(.*?)(?=\n##\s|\Z)",
r"##\s+Minor\s+Gaps\s*\n(.*?)(?=\n##\s|\Z)",
r"##\s+Gaps\s*\n(.*?)(?=\n##\s|\Z)",
r"##\s+Gaps\s+Identified\s*\n(.*?)(?=\n##\s|\Z)",
r"###\s+Gaps(?:\s+or\s+Minor\s+Gaps)?\s*\n(.*?)(?=\n##\s|\n###\s|\Z)",
r"###\s+Gaps\s+Identified\s*\n(.*?)(?=\n##\s|\n###\s|\Z)",
]
for pat in patterns:
m = re.search(pat, text, re.DOTALL)
if m:
bullets = parse_numbered_bullets(m.group(1))
if bullets:
return bullets
return []
# ─────────────────────────────────────────────
# Q FILE PARSING
# ─────────────────────────────────────────────
def parse_qfile(path: Path) -> dict:
text = path.read_text(encoding="utf-8")
m = re.search(r"^#\s+Question\s+(\d+)\s+Analysis:\s*(.+)$", text, re.MULTILINE)
if not m:
raise ValueError(f"Cannot parse title in {path.name}")
q_num = int(m.group(1))
topic = m.group(2).strip()
m = re.search(r"\*\*Pillar:\*\*\s*(.+)", text)
pillar = m.group(1).strip() if m else "UNKNOWN"
m = re.search(r"\*\*Score:\s*(\d(?:\.\d+)?)", text)
score = float(m.group(1)) if m else 0.0
m = re.search(
r"##\s+Executive Summary\s*\n(.*?)(?=\n##\s|\Z)",
text,
re.DOTALL,
)
exec_summary = m.group(1).strip() if m else ""
return {
"num": q_num,
"topic": topic,
"pillar": pillar,
"score": score,
"level": SCORE_LEVEL.get(max(1, min(4, round(score))), "Unknown"),
"exec_summary": exec_summary,
"strengths": extract_strengths(text),
"gaps": extract_gaps(text),
}
def load_questions(q_dir: Path) -> list[dict]:
"""Load all Q files, deduplicating by question number (keep highest-numbered filename)."""
files = sorted(
[
f for f in q_dir.glob("Question_*_Analysis.md")
if not any(x in f.name.lower() for x in ("original", "test"))
],
key=lambda f: int(re.search(r"Question_(\d+)_Analysis", f.name).group(1)),
)
# Deduplicate: if two files have the same q_num, last one wins
seen: dict[int, Path] = {}
for f in files:
n = int(re.search(r"Question_(\d+)_Analysis", f.name).group(1))
seen[n] = f
deduped = sorted(seen.values(), key=lambda f: int(re.search(r"Question_(\d+)_Analysis", f.name).group(1)))
questions = []
for f in deduped:
try:
q = parse_qfile(f)
questions.append(q)
except Exception as e:
print(f" WARNING: could not parse {f.name}: {e}")
print(f" Loaded {len(questions)} questions from {q_dir}")
return questions
# ─────────────────────────────────────────────
# PDF IMPORTS
# ─────────────────────────────────────────────
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
from reportlab.lib import colors
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_JUSTIFY, TA_RIGHT
from reportlab.platypus import (
BaseDocTemplate, PageTemplate, Frame, Paragraph, Spacer,
PageBreak, Table, TableStyle, KeepTogether,
)
from reportlab.platypus.flowables import HRFlowable, Flowable
PAGE_W, PAGE_H = A4
ML = MR = 22 * mm
MT = MB = 26 * mm
CONTENT_W = PAGE_W - ML - MR
def hex_color(h: str):
return colors.HexColor(h.lstrip("#") and f"#{h.lstrip('#')}")
# ─────────────────────────────────────────────
# PAGE DECORATORS
# ─────────────────────────────────────────────
def make_footer_fn(footer_text: str):
def _draw(canvas, doc):
canvas.saveState()
page = canvas.getPageNumber()
canvas.setFillColor(hex_color(NAVY))
canvas.rect(0, 0, PAGE_W, MB * 0.7, fill=1, stroke=0)
canvas.setFont("Helvetica", 7.5)
canvas.setFillColor(colors.white)
canvas.drawCentredString(PAGE_W / 2, MB * 0.25, footer_text)
if page >= 2:
canvas.setFillColor(hex_color(NAVY))
canvas.setFont("Helvetica-Bold", 8)
canvas.drawRightString(PAGE_W - MR, PAGE_H - MT * 0.6, str(page))
canvas.restoreState()
return _draw
# ─────────────────────────────────────────────
# STYLES
# ─────────────────────────────────────────────
def make_styles():
s = {}
s["body"] = ParagraphStyle("body", fontName="Helvetica", fontSize=9, leading=14,
textColor=hex_color(BODY_GRAY), alignment=TA_JUSTIFY)
s["body_left"] = ParagraphStyle("body_left", parent=s["body"], alignment=TA_LEFT)
s["bold"] = ParagraphStyle("bold", parent=s["body"], fontName="Helvetica-Bold")
s["small"] = ParagraphStyle("small", fontName="Helvetica", fontSize=7.5, leading=11,
textColor=hex_color(BODY_GRAY), alignment=TA_LEFT)
s["cover_title"] = ParagraphStyle("cover_title", fontName="Helvetica-Bold", fontSize=32,
leading=40, textColor=hex_color(WHITE), alignment=TA_LEFT, spaceAfter=6)
s["cover_subtitle"] = ParagraphStyle("cover_subtitle", fontName="Helvetica", fontSize=13,
leading=18, textColor=hex_color(ACCENT), alignment=TA_LEFT)
s["section_h1"] = ParagraphStyle("section_h1", fontName="Helvetica-Bold", fontSize=16,
leading=22, textColor=hex_color(NAVY), spaceBefore=8, spaceAfter=6, alignment=TA_LEFT)
s["exec_summary"] = ParagraphStyle("exec_summary", parent=s["body"],
leftIndent=4, rightIndent=4, spaceBefore=2, spaceAfter=6)
s["bullet_title"] = ParagraphStyle("bullet_title", fontName="Helvetica-Bold", fontSize=9,
leading=13, textColor=hex_color(NAVY), leftIndent=10, firstLineIndent=-10)
s["bullet_body"] = ParagraphStyle("bullet_body", parent=s["body"],
leftIndent=10, firstLineIndent=0, spaceBefore=1, spaceAfter=4)
s["pillar_label"] = ParagraphStyle("pillar_label", fontName="Helvetica-Bold", fontSize=8,
leading=12, textColor=hex_color(ACCENT), spaceBefore=8, spaceAfter=2)
s["center"] = ParagraphStyle("center", fontName="Helvetica", fontSize=9, leading=13,
textColor=hex_color(BODY_GRAY), alignment=TA_CENTER)
return s
# ─────────────────────────────────────────────
# FLOWABLE HELPERS
# ─────────────────────────────────────────────
class CoverBanner(Flowable):
"""Navy banner that contains the market title and subtitle — fully self-contained."""
def __init__(self, label: str):
super().__init__()
self.label = label
self._v_pad = 18 * mm
self._title_style = ParagraphStyle("ct", fontName="Helvetica-Bold", fontSize=32,
leading=40, textColor=colors.white)
self._sub_style = ParagraphStyle("cs", fontName="Helvetica", fontSize=13,
leading=18, textColor=hex_color(ACCENT))
def wrap(self, availWidth, availHeight):
self.width = availWidth
self._tp = Paragraph(self.label, self._title_style)
self._sp = Paragraph(
"Content Maturity Assessment · April 2026 · Confidential",
self._sub_style,
)
_, self._th = self._tp.wrap(availWidth, 1000)
_, self._sh = self._sp.wrap(availWidth, 1000)
self.banner_h = self._v_pad + self._th + 6 + self._sh + self._v_pad
return (availWidth, self.banner_h)
def draw(self):
c = self.canv
c.saveState()
c.setFillColor(hex_color(NAVY))
c.rect(-ML, 0, PAGE_W, self.banner_h, fill=1, stroke=0)
c.restoreState()
sub_y = self._v_pad
title_y = sub_y + self._sh + 6
self._sp.drawOn(self.canv, 0, sub_y)
self._tp.drawOn(self.canv, 0, title_y)
class AccentLine(Flowable):
def __init__(self, width=None, thickness=3):
super().__init__()
self.line_width = width or CONTENT_W
self.thickness = thickness
def draw(self):
self.canv.saveState()
self.canv.setStrokeColor(hex_color(ACCENT))
self.canv.setLineWidth(self.thickness)
self.canv.line(0, 0, self.line_width, 0)
self.canv.restoreState()
def wrap(self, *args):
return (self.line_width, self.thickness + 2)
def pillar_header(pillar: str, avg: float, styles) -> list:
level_int = max(1, min(4, round(avg)))
heading_style = ParagraphStyle("ph", fontName="Helvetica-Bold", fontSize=13,
leading=16, textColor=colors.white)
score_style = ParagraphStyle("ps", fontName="Helvetica-Bold", fontSize=13,
leading=16, textColor=colors.white, alignment=TA_RIGHT)
lvl_name = SCORE_LEVEL.get(level_int, "")
data = [[Paragraph(pillar, heading_style),
Paragraph(f"{avg:.2f}{lvl_name}", score_style)]]
t = Table(data, colWidths=[CONTENT_W * 0.7, CONTENT_W * 0.3])
t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), hex_color(ACCENT)),
("ALIGN", (0, 0), (0, 0), "LEFT"),
("ALIGN", (1, 0), (1, 0), "RIGHT"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("LEFTPADDING", (0, 0), (0, -1), 10),
("RIGHTPADDING", (-1, 0), (-1, -1), 10),
]))
return [t, Spacer(1, 4)]
def question_score_table(questions: list[dict], styles) -> Table:
header_style = ParagraphStyle("th", fontName="Helvetica-Bold", fontSize=8,
leading=11, textColor=colors.white, alignment=TA_CENTER)
cell_style = ParagraphStyle("td", fontName="Helvetica", fontSize=8,
leading=11, textColor=hex_color(BODY_GRAY), alignment=TA_LEFT)
cell_center = ParagraphStyle("td_c", parent=cell_style, alignment=TA_CENTER)
rows = [[Paragraph("Q#", header_style), Paragraph("Topic", header_style),
Paragraph("Score", header_style), Paragraph("Level", header_style)]]
for q in questions:
level_int = max(1, min(4, round(q["score"])))
clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2]))
sc_style = ParagraphStyle("sc", fontName="Helvetica-Bold", fontSize=8,
leading=11, textColor=colors.white, alignment=TA_CENTER)
lv_style = ParagraphStyle("lv", fontName="Helvetica-Bold", fontSize=8,
leading=11, textColor=colors.white, alignment=TA_CENTER)
rows.append([
Paragraph(str(q["num"]), cell_center),
Paragraph(q["topic"], cell_style),
Paragraph(str(int(q["score"]) if q["score"] == int(q["score"]) else q["score"]), sc_style),
Paragraph(q["level"], lv_style),
])
col_w = [12 * mm, CONTENT_W - 12 * mm - 20 * mm - 28 * mm, 20 * mm, 28 * mm]
t = Table(rows, colWidths=col_w, repeatRows=1)
ts = [
("BACKGROUND", (0, 0), (-1, 0), hex_color(NAVY)),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("ALIGN", (0, 0), (-1, 0), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("LEFTPADDING", (0, 0), (-1, -1), 5),
("RIGHTPADDING", (0, 0), (-1, -1), 5),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, hex_color("#F5F5F5")]),
("GRID", (0, 0), (-1, -1), 0.3, hex_color("#DDDDDD")),
]
for i, q in enumerate(questions, start=1):
level_int = max(1, min(4, round(q["score"])))
clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2]))
ts.append(("BACKGROUND", (2, i), (3, i), clr))
ts.append(("TEXTCOLOR", (2, i), (3, i), colors.white))
t.setStyle(TableStyle(ts))
return t
def cover_pillar_table(pillars_data: list[dict], styles) -> Table:
header_style = ParagraphStyle("th", fontName="Helvetica-Bold", fontSize=9,
leading=12, textColor=colors.white)
cell_style = ParagraphStyle("td", fontName="Helvetica", fontSize=9, leading=12,
textColor=hex_color(BODY_GRAY))
cell_center = ParagraphStyle("td_c", parent=cell_style, alignment=TA_CENTER)
cell_bold = ParagraphStyle("td_b", parent=cell_style, fontName="Helvetica-Bold")
rows = [[Paragraph("Pillar", header_style), Paragraph("Qs", header_style),
Paragraph("Avg Score", header_style), Paragraph("Level", header_style),
Paragraph("Min", header_style), Paragraph("Max", header_style)]]
for p in pillars_data:
level_int = max(1, min(4, round(p["avg"])))
sc_style = ParagraphStyle("sc", fontName="Helvetica-Bold", fontSize=9,
leading=12, textColor=colors.white, alignment=TA_CENTER)
lv_style = ParagraphStyle("lv", fontName="Helvetica-Bold", fontSize=9,
leading=12, textColor=colors.white, alignment=TA_CENTER)
rows.append([
Paragraph(p["name"], cell_bold),
Paragraph(str(p["count"]), cell_center),
Paragraph(f"{p['avg']:.2f}", sc_style),
Paragraph(p["level"], lv_style),
Paragraph(str(int(p["min"]) if p["min"] == int(p["min"]) else p["min"]), cell_center),
Paragraph(str(int(p["max"]) if p["max"] == int(p["max"]) else p["max"]), cell_center),
])
col_w = [CONTENT_W * 0.32, CONTENT_W * 0.08, CONTENT_W * 0.18,
CONTENT_W * 0.18, CONTENT_W * 0.12, CONTENT_W * 0.12]
t = Table(rows, colWidths=col_w, repeatRows=1)
ts = [
("BACKGROUND", (0, 0), (-1, 0), hex_color(NAVY)),
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, hex_color("#F0FAFB")]),
("GRID", (0, 0), (-1, -1), 0.3, hex_color("#CCCCCC")),
]
for i, p in enumerate(pillars_data, start=1):
level_int = max(1, min(4, round(p["avg"])))
clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2]))
ts.append(("BACKGROUND", (2, i), (3, i), clr))
ts.append(("TEXTCOLOR", (2, i), (3, i), colors.white))
t.setStyle(TableStyle(ts))
return t
# ─────────────────────────────────────────────
# MAIN PDF BUILD
# ─────────────────────────────────────────────
def build_pdf(questions: list[dict], output_path: Path, market_cfg: dict):
styles = make_styles()
label = market_cfg["label"]
footer_fn = make_footer_fn(market_cfg["footer"])
# Group by pillar
pillar_map: dict[str, list[dict]] = {p: [] for p in PILLAR_ORDER}
for q in questions:
matched = False
p_upper = q["pillar"].upper().strip()
for p in PILLAR_ORDER:
if p_upper == p:
pillar_map[p].append(q)
matched = True
break
if not matched:
for p in PILLAR_ORDER:
if p in p_upper:
pillar_map[p].append(q)
matched = True
break
if not matched:
print(f" WARNING: Q{q['num']} pillar '{q['pillar']}' not recognised")
def pillar_stats(qs):
if not qs:
return {"avg": 0, "count": 0, "min": 0, "max": 0, "level": "N/A"}
scores = [q["score"] for q in qs]
avg = sum(scores) / len(scores)
return {
"avg": avg,
"count": len(qs),
"min": min(scores),
"max": max(scores),
"level": SCORE_LEVEL.get(max(1, min(4, round(avg))), "Unknown"),
}
pillars_data = []
for p in PILLAR_ORDER:
stats = pillar_stats(pillar_map[p])
stats["name"] = p
stats["questions"] = pillar_map[p]
pillars_data.append(stats)
all_scores = [q["score"] for q in questions]
overall = sum(all_scores) / len(all_scores) if all_scores else 0
overall_level_int = max(1, min(4, round(overall)))
overall_level = SCORE_LEVEL.get(overall_level_int, "Unknown")
print(f" Overall score: {overall:.2f} ({overall_level})")
for p in pillars_data:
if p["count"] > 0:
print(f" {p['name']}: {p['avg']:.2f} ({p['level']}) — {p['count']} Qs")
# Document setup
doc = BaseDocTemplate(
str(output_path),
pagesize=A4,
leftMargin=ML, rightMargin=MR, topMargin=MT, bottomMargin=MB,
title=f"{label} — Content Maturity Assessment",
author="Oliver Agency",
)
frame = Frame(ML, MB, CONTENT_W, PAGE_H - MT - MB, id="normal")
template = PageTemplate(id="main", frames=[frame], onPage=footer_fn)
doc.addPageTemplates([template])
story = []
# ── COVER ──
story.append(CoverBanner(label))
story.append(Spacer(1, 8))
story.append(AccentLine(CONTENT_W, 3))
story.append(Spacer(1, 10))
# Overall score badge
overall_clr = hex_color(SCORE_COLOR.get(overall_level_int, SCORE_COLOR[2]))
badge_label_style = ParagraphStyle("bl", fontName="Helvetica-Bold", fontSize=10,
leading=14, textColor=hex_color(NAVY))
badge_score_style = ParagraphStyle("bs", fontName="Helvetica-Bold", fontSize=26,
leading=32, textColor=colors.white, alignment=TA_CENTER)
badge_level_style = ParagraphStyle("bls", fontName="Helvetica-Bold", fontSize=11,
leading=14, textColor=colors.white, alignment=TA_CENTER)
badge_sub_style = ParagraphStyle("bsub", fontName="Helvetica", fontSize=8,
leading=11, textColor=colors.white, alignment=TA_CENTER)
badge_data = [
[Paragraph(f"{overall:.2f}", badge_score_style)],
[Paragraph(f"Overall — {overall_level}", badge_level_style)],
[Paragraph("59 questions across 7 pillars", badge_sub_style)],
]
badge_t = Table(badge_data, colWidths=[55 * mm])
badge_t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), overall_clr),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 8),
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
("LEFTPADDING", (0, 0), (-1, -1), 8),
("RIGHTPADDING", (0, 0), (-1, -1), 8),
("ROUNDEDCORNERS", [5]),
]))
cover_label = Paragraph("Overall Maturity Score", badge_label_style)
badge_row = Table([[cover_label, badge_t]], colWidths=[CONTENT_W - 65 * mm, 65 * mm])
badge_row.setStyle(TableStyle([
("ALIGN", (0, 0), (0, 0), "LEFT"),
("ALIGN", (1, 0), (1, 0), "RIGHT"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 0),
("BOTTOMPADDING", (0, 0), (-1, -1), 0),
]))
story.append(badge_row)
story.append(Spacer(1, 12))
story.append(Paragraph("Pillar Summary", ParagraphStyle(
"pt", fontName="Helvetica-Bold", fontSize=12, leading=16,
textColor=hex_color(NAVY), spaceAfter=6)))
story.append(cover_pillar_table(pillars_data, styles))
story.append(PageBreak())
# ── PER-PILLAR SECTIONS ──
for pillar in pillars_data:
p_qs = pillar["questions"]
if not p_qs:
continue
story.extend(pillar_header(pillar["name"], pillar["avg"], styles))
story.append(question_score_table(p_qs, styles))
story.append(Spacer(1, 10))
for q in p_qs:
level_int = max(1, min(4, round(q["score"])))
clr = hex_color(SCORE_COLOR.get(level_int, SCORE_COLOR[2]))
chip_style = ParagraphStyle("chip", fontName="Helvetica-Bold", fontSize=8,
leading=11, textColor=colors.white, alignment=TA_CENTER)
topic_style = ParagraphStyle("qt", fontName="Helvetica-Bold", fontSize=10,
leading=14, textColor=hex_color(NAVY))
score_disp = int(q["score"]) if q["score"] == int(q["score"]) else q["score"]
chip_data = [[Paragraph(f"Q{q['num']} {score_disp} {q['level']}", chip_style)]]
chip_t = Table(chip_data, colWidths=[38 * mm])
chip_t.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), clr),
("ALIGN", (0, 0), (-1, -1), "CENTER"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("ROUNDEDCORNERS", [3]),
]))
topic_para = Paragraph(q["topic"], topic_style)
q_header = Table([[chip_t, topic_para]], colWidths=[40 * mm, CONTENT_W - 40 * mm])
q_header.setStyle(TableStyle([
("ALIGN", (0, 0), (0, 0), "LEFT"),
("ALIGN", (1, 0), (1, 0), "LEFT"),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 0),
("RIGHTPADDING", (0, 0), (-1, -1), 0),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]))
exec_text = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", q["exec_summary"])
exec_para = Paragraph(exec_text, styles["exec_summary"])
block = KeepTogether([
Spacer(1, 6),
q_header,
Spacer(1, 3),
exec_para,
HRFlowable(width=CONTENT_W, thickness=0.5,
color=hex_color("#DDDDDD"), spaceAfter=4),
])
story.append(block)
story.append(PageBreak())
# ── STRENGTHS SECTION ──
story.append(Paragraph("Strengths", styles["section_h1"]))
story.append(AccentLine(CONTENT_W * 0.4, 2))
story.append(Spacer(1, 8))
strengths_found = False
for pillar in pillars_data:
p_qs_with_strengths = [q for q in pillar["questions"] if q["strengths"]]
if not p_qs_with_strengths:
continue
strengths_found = True
story.append(Paragraph(pillar["name"], styles["pillar_label"]))
for q in p_qs_with_strengths:
for title, body in q["strengths"]:
body_clean = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", body.strip())
story.append(Paragraph(f"<b>Q{q['num']}: {q['topic']}</b>",
styles["bullet_title"]))
story.append(Paragraph(f"<b>{title}:</b> {body_clean}",
styles["bullet_body"]))
if not strengths_found:
story.append(Paragraph("No structured strengths identified in Q files.",
styles["body_left"]))
story.append(PageBreak())
# ── GAPS SECTION ──
story.append(Paragraph("Gaps", styles["section_h1"]))
story.append(AccentLine(CONTENT_W * 0.4, 2))
story.append(Spacer(1, 8))
gaps_found = False
for pillar in pillars_data:
p_qs_with_gaps = [q for q in pillar["questions"] if q["gaps"]]
if not p_qs_with_gaps:
continue
gaps_found = True
story.append(Paragraph(pillar["name"], styles["pillar_label"]))
for q in p_qs_with_gaps:
for title, body in q["gaps"]:
body_clean = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", body.strip())
story.append(Paragraph(f"<b>Q{q['num']}: {q['topic']}</b>",
styles["bullet_title"]))
story.append(Paragraph(f"<b>{title}:</b> {body_clean}",
styles["bullet_body"]))
if not gaps_found:
story.append(Paragraph("No structured gaps identified in Q files.",
styles["body_left"]))
doc.build(story)
# ─────────────────────────────────────────────
# ENTRY POINT
# ─────────────────────────────────────────────
def main():
if len(sys.argv) < 2 or sys.argv[1] not in MARKETS:
print("Usage: python3 generate_summary.py <MARKET_ID>")
print(f"Markets: {', '.join(MARKETS.keys())}")
sys.exit(1)
market_id = sys.argv[1]
cfg = MARKETS[market_id]
print(f"\n{market_id} — Maturity Assessment PDF Generator")
print("=" * 55)
# Validate Q dir
if not cfg["q_dir"].exists():
print(f"ERROR: Q directory not found:\n {cfg['q_dir']}")
sys.exit(1)
# Create output dirs
cfg["out_dir"].mkdir(parents=True, exist_ok=True)
cfg["desktop_dir"].mkdir(parents=True, exist_ok=True)
output_path = cfg["out_dir"] / cfg["filename"]
print(f"\nParsing question files from:\n {cfg['q_dir']}")
questions = load_questions(cfg["q_dir"])
if not questions:
print("ERROR: No questions loaded.")
sys.exit(1)
# Strengths/gaps summary
s_count = sum(1 for q in questions if q["strengths"])
g_count = sum(1 for q in questions if q["gaps"])
print(f" Questions with strengths: {s_count}/{len(questions)}")
print(f" Questions with gaps: {g_count}/{len(questions)}")
print(f"\nBuilding PDF → {output_path}")
build_pdf(questions, output_path, cfg)
size_kb = output_path.stat().st_size / 1024
print(f"\nOutput PDF: {output_path}")
print(f"File size: {size_kb:.1f} KB")
dest = cfg["desktop_dir"] / cfg["filename"]
shutil.copy2(output_path, dest)
print(f"Copied to: {dest}")
print("\nDone.")
if __name__ == "__main__":
main()