WISE_Energy / src /pages /peer_benchmarking.py
ahanbose's picture
Update src/pages/peer_benchmarking.py
3871496 verified
"""
pages/peer_benchmarking.py
──────────────────────────────────────────────────────────────────────────────
Peer Institution Benchmarking β€” Visual Edition
Architecture:
β€’ Each uploaded report is parsed via core.bench_processor.parse_peer_report
into text chunks (PDF / DOCX / TXT / CSV / XLSX all supported).
β€’ SPJIMR context is built from session_state + optional manual text input.
β€’ Four structured LLM calls produce text + machine-readable data tags:
1. Peer summaries β†’ disclosure coverage heatmap
2. Gap analysis β†’ gap magnitude bar chart + severity donut
3. Ranking β†’ radar chart + grouped bar + podium
4. Action plan β†’ effort-impact bubble chart + horizon timeline
Structured data tags emitted by LLM
─────────────────────────────────────
SCORES:: β€” per-institution dimension scores (ranking)
GAPDATA:: β€” numeric gap data per metric (gap analysis)
ACTIONDATA:: β€” effort/impact per action item (action plan)
Session-state keys
──────────────────
peer_institutions dict[name, {"chunks": list[str], "file": str}]
peer_summaries dict[name, str]
bench_gap_report str
bench_gap_data list[dict] <- parsed GAPDATA:: lines
bench_ranking str
bench_ranking_scores dict[name, dict[dim, float]]
bench_action_plan str
bench_action_data list[dict] <- parsed ACTIONDATA:: lines
bench_spjimr_manual str
"""
from __future__ import annotations
import logging
import re
import textwrap
from pathlib import Path
import streamlit as st
from core.bench_processor import parse_peer_report, SUPPORTED_FORMATS
logger = logging.getLogger(__name__)
# ── Constants ──────────────────────────────────────────────────────────────────
RANK_DIMENSIONS = [
"Renewable Energy",
"Waste & Circularity",
"Water Conservation",
"Carbon Reporting",
"SDG & Disclosure",
]
DIM_COLORS = ["#F1C40F", "#2ECC71", "#3498DB", "#E74C3C", "#9B59B6"]
INST_PALETTE = [
"#27AE60", # SPJIMR β€” green
"#E74C3C", "#3498DB", "#F39C12",
"#9B59B6", "#1ABC9C", "#E67E22", "#34495E",
]
_SEVERITY_COLOR = {
"Critical": "#E74C3C",
"Moderate": "#F39C12",
"Strength": "#2ECC71",
}
_EFFORT_NUM = {"Low": 1, "Medium": 2, "High": 3}
_IMPACT_NUM = {"Low": 1, "Medium": 2, "High": 3}
_HORIZON_ORDER = {"QuickWin": 0, "MediumTerm": 1, "Strategic": 2}
_HORIZON_LABEL = {
"QuickWin": "Quick Win (0-3 mo)",
"MediumTerm": "Medium Term (3-12 mo)",
"Strategic": "Strategic (1-3 yr)",
}
_HORIZON_COLOR = {
"QuickWin": "#2ECC71",
"MediumTerm": "#3498DB",
"Strategic": "#9B59B6",
}
# ══════════════════════════════════════════════════════════════════════════════
# Context helpers
# ══════════════════════════════════════════════════════════════════════════════
def _get_hf_token() -> str:
return st.session_state.get("hf_token", "")
def _spjimr_context_summary() -> str:
manual = st.session_state.get("bench_spjimr_manual", "").strip()
lines: list[str] = ["=== SPJIMR Current Sustainability Data ===\n"]
if manual:
lines.append("--- Analyst-provided context ---")
lines.append(manual)
lines.append("")
edf = st.session_state.get("energy_df")
if edf is not None and not edf.empty:
ren_pct = edf.iloc[-1].get("renewable_pct", None)
lines.append(
f"Energy - Latest renewable %: {ren_pct:.1f}%" if ren_pct is not None
else "Energy - renewable % not computed"
)
lines.append(f"Energy - Periods tracked: {len(edf)}")
else:
lines.append("Energy - No structured data loaded.")
wdf = st.session_state.get("water_df")
if wdf is not None and not wdf.empty:
total = wdf["total_kl"].sum() if "total_kl" in wdf.columns else 0
rain = wdf["rainwater_kl"].sum() if "rainwater_kl" in wdf.columns else 0
rain_pct = (rain / total * 100) if total > 0 else 0
lines.append(f"Water - Total consumed: {total:,.0f} kL")
lines.append(f"Water - Rainwater harvesting %: {rain_pct:.1f}%")
else:
lines.append("Water - No structured data loaded.")
wst = st.session_state.get("waste_full")
if wst is not None and not wst.empty:
rec_pct = float(wst["recovered_pct"].iloc[-1]) if "recovered_pct" in wst.columns else 0
lines.append(f"Waste - Latest recovery rate: {rec_pct:.1f}%")
else:
wdf2 = st.session_state.get("waste_all_df") or st.session_state.get("waste_df")
if wdf2 is not None and not wdf2.empty:
lines.append(f"Waste - Granular data available ({len(wdf2)} records)")
else:
lines.append("Waste - No structured data loaded.")
consultant = st.session_state.get("consultant")
if consultant and consultant.is_ready:
try:
chunks = consultant.store.search(
"SPJIMR sustainability achievements energy water waste carbon SDG", top_k=6
)
if chunks:
lines.append("\n=== SPJIMR Document Context (RAG) ===")
lines.extend(chunks)
except Exception:
pass
return "\n".join(lines)
# ══════════════════════════════════════════════════════════════════════════════
# LLM calls
# ══════════════════════════════════════════════════════════════════════════════
def _llm_call(system: str, user: str, max_tokens: int = 1500) -> str:
from core.consultant import _chat_completion
return _chat_completion(
messages=[{"role": "system", "content": system}, {"role": "user", "content": user}],
hf_token=_get_hf_token(), model_key="strategy", max_tokens=max_tokens, temperature=0.3,
)
def _institution_summary(name: str, chunks: list[str]) -> str:
context = "\n\n---\n\n".join(chunks[:10])
system = textwrap.dedent("""
You are an expert ESG analyst specialising in higher education sustainability.
Extract and summarise sustainability performance data from the provided document.
Be precise, cite numbers where available, and flag anything not reported.
""").strip()
user = textwrap.dedent(f"""
Institution: {name}
Document content:
{context}
Provide a structured summary covering:
1. Renewable Energy - % share, absolute consumption, targets
2. Waste Management - diversion/recovery rate, initiatives, targets
3. Water - consumption, harvesting, reduction measures
4. Carbon / Emissions - Scope 1/2/3 if reported, net-zero targets
5. SDG Alignment - which SDGs cited, disclosure standard used (GRI/BRSR/other)
6. Notable Initiatives - any standout programmes or achievements
Use N/A where data is not available in the document.
""").strip()
return _llm_call(system, user, max_tokens=900)
def _gap_analysis(spjimr_context: str, peer_summaries: dict[str, str]) -> str:
peers_text = "\n\n".join(f"### {n}\n{s}" for n, s in peer_summaries.items())
system = textwrap.dedent("""
You are a senior sustainability benchmarking consultant.
Identify performance gaps and deficiencies for SPJIMR vs peer institutions.
Be direct. Use specific numbers wherever available. Do not soften findings.
""").strip()
user = textwrap.dedent(f"""
SPJIMR Current Performance:
{spjimr_context}
Peer Institution Summaries:
{peers_text}
FIRST, output structured gap data lines (one per metric) in EXACTLY this format
(no spaces around ::):
GAPDATA::<metric_name>::SPJIMR=<value_or_NA>::BestPeer=<institution_name>::BestPeerValue=<value_or_NA>::Severity=<Critical|Moderate|Strength>
Include at least 8 metrics covering: renewable energy %, waste recovery rate,
water recycling %, carbon reporting (1=yes/0=no), net-zero target (1=yes/0=no),
GRI/BRSR disclosure (1=yes/0=no), SDGs reported (count), notable programmes (count).
Use numeric values where possible (e.g. 42 for 42%).
THEN provide the narrative gap analysis:
## Critical Gaps (SPJIMR significantly behind peers)
For each gap: metric | SPJIMR value | best peer value | magnitude of gap.
## Moderate Gaps (SPJIMR behind but within reach)
Same format.
## Areas Where SPJIMR Leads or Is On Par
Brief acknowledgement of strengths.
## Data and Disclosure Gaps
Metrics peers report that SPJIMR does not.
""").strip()
return _llm_call(system, user, max_tokens=2000)
def _rank_institutions(spjimr_context: str, peer_summaries: dict[str, str]) -> str:
peers_text = "\n\n".join(f"### {n}\n{s}" for n, s in peer_summaries.items())
all_inst = ["SPJIMR"] + list(peer_summaries.keys())
system = textwrap.dedent("""
You are a sustainability benchmarking expert.
Score each institution 1-10 per dimension. Penalise for non-disclosure.
IMPORTANT: Output one SCORES:: line per institution BEFORE the narrative:
SCORES::<institution_name>::RenewableEnergy=<n>::WasteCircularity=<n>::WaterConservation=<n>::CarbonReporting=<n>::SDGDisclosure=<n>
""").strip()
user = textwrap.dedent(f"""
Institutions: {", ".join(all_inst)}
SPJIMR Data:
{spjimr_context}
Peer Summaries:
{peers_text}
Output SCORES:: lines first (one per institution), then narrative.
Score all {len(all_inst)} institutions across:
1. Renewable Energy & Clean Power
2. Waste Reduction & Circularity
3. Water Conservation & Harvesting
4. Carbon Reporting & Net-Zero Ambition
5. SDG Alignment & Transparency
End with OVERALL RANKING table:
Rank | Institution | Overall Score | Key Strength | Key Gap
""").strip()
return _llm_call(system, user, max_tokens=2200)
def _action_plan(spjimr_context: str, gap_analysis: str, ranking: str) -> str:
system = textwrap.dedent("""
You are a sustainability implementation consultant for SPJIMR leadership.
Recommendations must be specific, feasible for an Indian management institution,
tied to identified gaps. Every item must name its peer inspiration.
""").strip()
user = textwrap.dedent(f"""
SPJIMR Current State:
{spjimr_context}
Gap Analysis:
{gap_analysis}
Benchmarking Ranking:
{ranking}
FIRST, output structured action data lines in EXACTLY this format:
ACTIONDATA::<short_action_title>::Horizon=<QuickWin|MediumTerm|Strategic>::Effort=<Low|Medium|High>::Impact=<Low|Medium|High>::InspiredBy=<institution>
Include one ACTIONDATA:: line for EACH action item (at least 10 total).
THEN provide the full narrative action plan:
## Quick Wins (0-3 months)
Each: Action | Inspired by | Expected impact | Owner
## Medium-Term Initiatives (3-12 months)
Each: Initiative | Inspired by | KPI to track | Investment level
## Strategic Priorities (1-3 years)
Each: Strategic goal | Benchmark target | Milestone | SDG alignment
## Disclosure and Reporting Improvements
Specific metrics SPJIMR should start measuring and disclosing immediately.
""").strip()
return _llm_call(system, user, max_tokens=2200)
# ══════════════════════════════════════════════════════════════════════════════
# Structured data parsers
# ══════════════════════════════════════════════════════════════════════════════
def _parse_scores(text: str) -> dict[str, dict[str, float]]:
scores: dict[str, dict[str, float]] = {}
pat = re.compile(
r"SCORES::([^:]+)::RenewableEnergy=(\d+(?:\.\d+)?)"
r"::WasteCircularity=(\d+(?:\.\d+)?)"
r"::WaterConservation=(\d+(?:\.\d+)?)"
r"::CarbonReporting=(\d+(?:\.\d+)?)"
r"::SDGDisclosure=(\d+(?:\.\d+)?)",
re.IGNORECASE,
)
for m in pat.finditer(text):
scores[m.group(1).strip()] = {
"Renewable Energy": float(m.group(2)),
"Waste & Circularity": float(m.group(3)),
"Water Conservation": float(m.group(4)),
"Carbon Reporting": float(m.group(5)),
"SDG & Disclosure": float(m.group(6)),
}
return scores
def _fallback_scores(text: str, names: list[str]) -> dict[str, dict[str, float]]:
scores: dict[str, dict[str, float]] = {}
for inst in names:
pat = re.compile(rf"{re.escape(inst)}.*?(\d+(?:\.\d+)?)", re.IGNORECASE | re.DOTALL)
vals = [float(m.group(1)) for m in pat.finditer(text) if float(m.group(1)) <= 10]
if len(vals) >= 5:
scores[inst] = {dim: vals[i] for i, dim in enumerate(RANK_DIMENSIONS)}
return scores
def _parse_gap_data(text: str) -> list[dict]:
gaps = []
pat = re.compile(
r"GAPDATA::([^:]+)::SPJIMR=([^:]+)::BestPeer=([^:]+)"
r"::BestPeerValue=([^:]+)::Severity=(Critical|Moderate|Strength)",
re.IGNORECASE,
)
def _to_num(s: str):
try: return float(re.sub(r"[^0-9.\-]", "", s))
except: return None # noqa
for m in pat.finditer(text):
severity = m.group(5).strip().capitalize()
gaps.append({
"metric": m.group(1).strip(),
"spjimr": _to_num(m.group(2).strip()),
"spjimr_raw": m.group(2).strip(),
"best_peer": m.group(3).strip(),
"peer_val": _to_num(m.group(4).strip()),
"peer_val_raw": m.group(4).strip(),
"severity": severity if severity in _SEVERITY_COLOR else "Moderate",
})
return gaps
def _parse_action_data(text: str) -> list[dict]:
actions = []
pat = re.compile(
r"ACTIONDATA::([^:]+)::Horizon=(QuickWin|MediumTerm|Strategic)"
r"::Effort=(Low|Medium|High)::Impact=(Low|Medium|High)::InspiredBy=([^\n]+)",
re.IGNORECASE,
)
for m in pat.finditer(text):
horizon = m.group(2).strip()
effort = m.group(3).strip().capitalize()
impact = m.group(4).strip().capitalize()
actions.append({
"title": m.group(1).strip(),
"horizon": horizon,
"effort": effort,
"impact": impact,
"inspired_by": m.group(5).strip(),
"effort_num": _EFFORT_NUM.get(effort, 2),
"impact_num": _IMPACT_NUM.get(impact, 2),
})
return actions
# ══════════════════════════════════════════════════════════════════════════════
# Chart renderers β€” Tab 1 (Summaries)
# ══════════════════════════════════════════════════════════════════════════════
def _render_disclosure_heatmap(summaries: dict[str, str], spjimr_ctx: str) -> None:
"""Heatmap: rows=dimensions, cols=institutions. Green=reported, Amber=N/A, Red=absent."""
import plotly.graph_objects as go
dim_keywords = {
"Renewable Energy": ["renewable", "solar", "wind", "clean energy", "kwh", "energy mix"],
"Waste & Circularity": ["waste", "diversion", "recovery", "recycl", "compost"],
"Water Conservation": ["water", "rainwater", "harvesting", "kl", "litre"],
"Carbon / Emissions": ["carbon", "emission", "co2", "scope", "ghg", "net zero"],
"SDG & Disclosure": ["sdg", "gri", "brsr", "tcfd", "disclosure", "report"],
"Notable Initiatives": ["initiative", "programme", "project", "campaign", "green"],
}
def _score(text: str, keywords: list[str]) -> int:
t = text.lower()
if not any(kw in t for kw in keywords):
return 0
if "n/a" in t or "not reported" in t or "not disclosed" in t:
return 1
return 2
all_insts = {"SPJIMR": spjimr_ctx, **{n: s for n, s in summaries.items()}}
dims = list(dim_keywords.keys())
inst_list = list(all_insts.keys())
z, text_m = [], []
for dim, keywords in dim_keywords.items():
row_z, row_t = [], []
for inst in inst_list:
s = _score(all_insts[inst], keywords)
row_z.append(s)
row_t.append(["Not reported", "Mentioned/N/A", "Reported"][s])
z.append(row_z)
text_m.append(row_t)
fig = go.Figure(go.Heatmap(
z=z, x=inst_list, y=dims,
text=text_m, texttemplate="%{text}", textfont={"size": 10},
colorscale=[[0, "#E74C3C"], [0.5, "#F39C12"], [1.0, "#2ECC71"]],
zmin=0, zmax=2, showscale=False,
))
fig.update_layout(
title=dict(text="Sustainability Disclosure Coverage Matrix", font=dict(size=14)),
xaxis=dict(tickfont=dict(size=11), side="bottom"),
yaxis=dict(tickfont=dict(size=11), autorange="reversed"),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
margin=dict(t=50, b=40, l=160, r=20),
height=320,
)
st.plotly_chart(fig, use_container_width=True)
lc = st.columns(3)
for col, (label, color) in zip(lc, [
("Reported with data", "#2ECC71"),
("Mentioned / N/A", "#F39C12"),
("Not reported", "#E74C3C"),
]):
col.markdown(
f'<div style="background:{color}22;border:1px solid {color}55;border-radius:6px;'
f'padding:0.3rem 0.8rem;text-align:center;font-size:0.78rem;color:{color};">'
f'{label}</div>',
unsafe_allow_html=True,
)
# ══════════════════════════════════════════════════════════════════════════════
# Chart renderers β€” Tab 2 (Gap Analysis)
# ══════════════════════════════════════════════════════════════════════════════
def _render_gap_summary_metrics(gap_data: list[dict]) -> None:
if not gap_data:
return
from collections import Counter
counts = Counter(g["severity"] for g in gap_data)
cols = st.columns(3)
for col, (sev, color) in zip(cols, [
("Critical", "#E74C3C"), ("Moderate", "#F39C12"), ("Strength", "#2ECC71"),
]):
n = counts.get(sev, 0)
col.markdown(
f'<div style="background:{color}18;border:2px solid {color}44;border-radius:12px;'
f'padding:1rem;text-align:center;">'
f'<div style="font-size:2rem;font-weight:700;color:{color};">{n}</div>'
f'<div style="font-size:0.78rem;color:{color}cc;">{sev} Gap{"s" if n != 1 else ""}</div>'
f'</div>',
unsafe_allow_html=True,
)
def _render_gap_bars(gap_data: list[dict]) -> None:
"""Horizontal grouped bar: SPJIMR vs best peer per metric."""
import plotly.graph_objects as go
numeric = [g for g in gap_data if g["spjimr"] is not None and g["peer_val"] is not None]
if not numeric:
st.info("No numeric gap data available for chart.")
return
numeric.sort(key=lambda g: abs((g["peer_val"] or 0) - (g["spjimr"] or 0)), reverse=True)
metrics = [g["metric"] for g in numeric]
spj_vals = [g["spjimr"] for g in numeric]
peer_vals = [g["peer_val"] for g in numeric]
bar_colors = [_SEVERITY_COLOR.get(g["severity"], "#F39C12") for g in numeric]
fig = go.Figure()
fig.add_trace(go.Bar(
name="SPJIMR", y=metrics, x=spj_vals, orientation="h",
marker_color="#27AE60", marker_line_width=0,
text=[f"{v:.1f}" for v in spj_vals], textposition="outside",
))
fig.add_trace(go.Bar(
name="Best Peer", y=metrics, x=peer_vals, orientation="h",
marker_color=bar_colors, marker_line_width=0, opacity=0.78,
text=[f"{v:.1f} ({g['best_peer']})" for v, g in zip(peer_vals, numeric)],
textposition="outside",
))
fig.update_layout(
barmode="group",
title=dict(text="SPJIMR vs Best Peer β€” Key Metrics", font=dict(size=14)),
xaxis=dict(title="Value", tickfont=dict(size=10), gridcolor="rgba(255,255,255,0.1)"),
yaxis=dict(tickfont=dict(size=10), autorange="reversed"),
legend=dict(orientation="h", y=1.06, x=0),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
margin=dict(t=60, b=40, l=180, r=130),
height=max(350, len(numeric) * 50),
)
st.plotly_chart(fig, use_container_width=True)
def _render_gap_severity_donut(gap_data: list[dict]) -> None:
"""Donut showing proportion of critical / moderate / strength gaps."""
import plotly.graph_objects as go
from collections import Counter
if not gap_data:
return
counts = Counter(g["severity"] for g in gap_data)
labels = list(counts.keys())
values = [counts[l] for l in labels]
colors = [_SEVERITY_COLOR.get(l, "#95A5A6") for l in labels]
fig = go.Figure(go.Pie(
labels=labels, values=values, hole=0.55,
marker=dict(colors=colors, line=dict(color="rgba(0,0,0,0.3)", width=2)),
textinfo="label+percent", textfont=dict(size=12),
))
fig.add_annotation(
text=f"<b>{len(gap_data)}</b><br>metrics",
x=0.5, y=0.5, font=dict(size=14, color="#FAF7F0"), showarrow=False,
)
fig.update_layout(
title=dict(text="Gap Severity Distribution", font=dict(size=13)),
showlegend=True,
legend=dict(font=dict(size=11), bgcolor="rgba(0,0,0,0)"),
paper_bgcolor="rgba(0,0,0,0)",
margin=dict(t=50, b=10, l=10, r=10),
height=300,
)
st.plotly_chart(fig, use_container_width=True)
# ══════════════════════════════════════════════════════════════════════════════
# Chart renderers β€” Tab 3 (Ranking)
# ══════════════════════════════════════════════════════════════════════════════
def _render_radar_chart(scores: dict[str, dict[str, float]]) -> None:
import plotly.graph_objects as go
dims = RANK_DIMENSIONS
theta = dims + [dims[0]]
inst_list = ["SPJIMR"] + [i for i in scores if i != "SPJIMR"] if "SPJIMR" in scores else list(scores)
fig = go.Figure()
for i, inst in enumerate(inst_list):
dim_scores = scores[inst]
values = [dim_scores.get(d, 0) for d in dims] + [dim_scores.get(dims[0], 0)]
color = INST_PALETTE[i % len(INST_PALETTE)]
fig.add_trace(go.Scatterpolar(
r=values, theta=theta, fill="toself", name=inst,
line=dict(color=color, width=2.5), opacity=0.20,
))
fig.update_layout(
polar=dict(
radialaxis=dict(visible=True, range=[0, 10], tickfont=dict(size=9),
gridcolor="rgba(255,255,255,0.15)"),
angularaxis=dict(tickfont=dict(size=12), gridcolor="rgba(255,255,255,0.15)"),
bgcolor="rgba(0,0,0,0)",
),
paper_bgcolor="rgba(0,0,0,0)",
legend=dict(font=dict(size=11), bgcolor="rgba(0,0,0,0.3)",
bordercolor="rgba(255,255,255,0.2)", borderwidth=1),
margin=dict(t=20, b=20, l=20, r=20),
height=460,
)
st.plotly_chart(fig, use_container_width=True)
def _render_grouped_bar(scores: dict[str, dict[str, float]]) -> None:
"""Grouped bar: dimensions on x-axis, one bar cluster per institution."""
import plotly.graph_objects as go
inst_list = ["SPJIMR"] + [i for i in scores if i != "SPJIMR"] if "SPJIMR" in scores else list(scores)
fig = go.Figure()
for i, inst in enumerate(inst_list):
dim_scores = scores[inst]
fig.add_trace(go.Bar(
name=inst, x=RANK_DIMENSIONS,
y=[dim_scores.get(d, 0) for d in RANK_DIMENSIONS],
marker_color=INST_PALETTE[i % len(INST_PALETTE)],
marker_line_width=0,
text=[f"{dim_scores.get(d, 0):.1f}" for d in RANK_DIMENSIONS],
textposition="outside", textfont=dict(size=10),
))
fig.update_layout(
barmode="group",
title=dict(text="Dimension-by-Dimension Score Comparison", font=dict(size=14)),
xaxis=dict(tickfont=dict(size=11)),
yaxis=dict(range=[0, 12], title="Score / 10", tickfont=dict(size=10),
gridcolor="rgba(255,255,255,0.1)"),
legend=dict(font=dict(size=11), bgcolor="rgba(0,0,0,0.2)"),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
margin=dict(t=50, b=60, l=60, r=20),
height=400,
)
st.plotly_chart(fig, use_container_width=True)
def _render_podium(scores: dict[str, dict[str, float]]) -> None:
"""Overall score podium with medal colours + SPJIMR highlighted in green."""
import plotly.graph_objects as go
import pandas as pd
rows = []
for inst, dim_scores in scores.items():
avg = sum(dim_scores.get(d, 0) for d in RANK_DIMENSIONS) / len(RANK_DIMENSIONS)
rows.append({"Institution": inst, "Overall": round(avg, 2)})
df = (
pd.DataFrame(rows)
.sort_values("Overall", ascending=False)
.reset_index(drop=True)
)
medal = {0: "#D4AF37", 1: "#C0C0C0", 2: "#CD7F32"}
bar_clr = []
for i, row in df.iterrows():
if row["Institution"] == "SPJIMR":
bar_clr.append("#27AE60")
elif i < 3:
bar_clr.append(medal.get(i, "#3498DB"))
else:
bar_clr.append("#3498DB")
fig = go.Figure(go.Bar(
x=df["Institution"], y=df["Overall"],
marker_color=bar_clr, marker_line_width=0,
text=[f"{v:.1f}" for v in df["Overall"]],
textposition="outside", textfont=dict(size=13, color="#FAF7F0"),
))
for rank_i, emoji in enumerate(["1st", "2nd", "3rd"]):
if rank_i < len(df):
fig.add_annotation(
x=df.iloc[rank_i]["Institution"],
y=df.iloc[rank_i]["Overall"] + 0.6,
text=emoji, font=dict(size=11, color="#FAF7F0"),
showarrow=False,
)
fig.update_layout(
title=dict(text="Overall Sustainability Score Ranking", font=dict(size=14)),
xaxis=dict(tickfont=dict(size=11)),
yaxis=dict(range=[0, 12], title="Overall Score / 10",
gridcolor="rgba(255,255,255,0.1)"),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
margin=dict(t=60, b=40, l=60, r=20),
height=360,
)
st.plotly_chart(fig, use_container_width=True)
def _render_score_table(scores: dict[str, dict[str, float]]) -> None:
import pandas as pd
rows = []
for inst, dim_scores in scores.items():
row = {"Institution": inst}
total = 0.0
for dim in RANK_DIMENSIONS:
v = dim_scores.get(dim, 0)
row[dim] = v
total += v
row["Overall"] = round(total / len(RANK_DIMENSIONS), 1)
rows.append(row)
df = (
pd.DataFrame(rows)
.sort_values("Overall", ascending=False)
.reset_index(drop=True)
)
df.index += 1
def _highlight(row):
if row["Institution"] == "SPJIMR":
return ["background-color: rgba(39,174,96,0.18)"] * len(row)
return [""] * len(row)
styled = (
df.style
.apply(_highlight, axis=1)
.format({c: "{:.1f}" for c in RANK_DIMENSIONS + ["Overall"]})
.set_properties(**{"text-align": "center"})
)
st.dataframe(styled, use_container_width=True)
# ══════════════════════════════════════════════════════════════════════════════
# Chart renderers β€” Tab 4 (Action Plan)
# ══════════════════════════════════════════════════════════════════════════════
def _render_effort_impact_chart(action_data: list[dict]) -> None:
"""Bubble chart: x=Effort, y=Impact, colour=Horizon. Top-right = prioritise."""
import plotly.graph_objects as go
import random
if not action_data:
st.info("No structured action data extracted.")
return
random.seed(42)
fig = go.Figure()
for horizon in ["QuickWin", "MediumTerm", "Strategic"]:
items = [a for a in action_data if a["horizon"] == horizon]
if not items:
continue
color = _HORIZON_COLOR[horizon]
fig.add_trace(go.Scatter(
x=[a["effort_num"] + random.uniform(-0.12, 0.12) for a in items],
y=[a["impact_num"] + random.uniform(-0.12, 0.12) for a in items],
mode="markers+text",
name=_HORIZON_LABEL[horizon],
text=[a["title"][:28] + ("..." if len(a["title"]) > 28 else "") for a in items],
textposition="top center",
textfont=dict(size=9, color="#FAF7F0"),
marker=dict(size=22, color=color,
line=dict(color="rgba(255,255,255,0.3)", width=1.5), opacity=0.85),
customdata=[[a["title"], a["inspired_by"]] for a in items],
hovertemplate=(
"<b>%{customdata[0]}</b><br>"
"Inspired by: %{customdata[1]}<br>"
"Effort: %{x:.0f}/3 | Impact: %{y:.0f}/3<extra></extra>"
),
))
# Quadrant shading
for x0, x1, y0, y1, fill in [
(0.5, 1.5, 2.5, 3.5, "rgba(46,204,113,0.08)"),
(2.5, 3.5, 2.5, 3.5, "rgba(52,152,219,0.08)"),
(0.5, 1.5, 0.5, 1.5, "rgba(149,165,166,0.05)"),
(2.5, 3.5, 0.5, 1.5, "rgba(231,76,60,0.08)"),
]:
fig.add_shape(type="rect", x0=x0, x1=x1, y0=y0, y1=y1,
fillcolor=fill, line_width=0, layer="below")
fig.add_annotation(x=1, y=3.35, text="Prioritise", font=dict(size=9, color="#2ECC71"),
showarrow=False)
fig.add_annotation(x=3, y=0.65, text="Deprioritise", font=dict(size=9, color="#E74C3C"),
showarrow=False)
fig.update_layout(
title=dict(text="Effort vs Impact Matrix", font=dict(size=14)),
xaxis=dict(title="Effort", range=[0.3, 3.7],
tickvals=[1, 2, 3], ticktext=["Low", "Medium", "High"],
tickfont=dict(size=11), gridcolor="rgba(255,255,255,0.1)", zeroline=False),
yaxis=dict(title="Impact", range=[0.3, 3.7],
tickvals=[1, 2, 3], ticktext=["Low", "Medium", "High"],
tickfont=dict(size=11), gridcolor="rgba(255,255,255,0.1)", zeroline=False),
legend=dict(font=dict(size=10), bgcolor="rgba(0,0,0,0.3)"),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
margin=dict(t=50, b=50, l=60, r=20),
height=450,
)
st.plotly_chart(fig, use_container_width=True)
def _render_action_timeline(action_data: list[dict]) -> None:
"""Gantt-style timeline grouped by horizon."""
import plotly.graph_objects as go
if not action_data:
return
sorted_actions = sorted(
action_data,
key=lambda a: (_HORIZON_ORDER.get(a["horizon"], 1), -a["impact_num"]),
)
h_ranges = {"QuickWin": (0, 3), "MediumTerm": (3, 12), "Strategic": (12, 36)}
titles = [
a["title"][:42] + ("..." if len(a["title"]) > 42 else "")
for a in sorted_actions
]
colors = [_HORIZON_COLOR.get(a["horizon"], "#3498DB") for a in sorted_actions]
labels = [_HORIZON_LABEL.get(a["horizon"], "") for a in sorted_actions]
x_start = [h_ranges.get(a["horizon"], (0, 6))[0] for a in sorted_actions]
x_width = [h_ranges.get(a["horizon"], (0, 6))[1]
- h_ranges.get(a["horizon"], (0, 6))[0] for a in sorted_actions]
fig = go.Figure()
seen_horizons: set = set()
for title, x0, xw, color, lbl in zip(titles, x_start, x_width, colors, labels):
show = lbl not in seen_horizons
seen_horizons.add(lbl)
fig.add_trace(go.Bar(
name=lbl, y=[title], x=[xw], base=[x0],
orientation="h", marker_color=color, marker_line_width=0,
opacity=0.80, legendgroup=lbl, showlegend=show,
hovertemplate=f"<b>{title}</b><br>{lbl}<extra></extra>",
))
for mo, label in [(3, "3 mo"), (12, "12 mo"), (36, "3 yr")]:
fig.add_vline(x=mo, line_width=1, line_dash="dash",
line_color="rgba(255,255,255,0.3)",
annotation_text=label, annotation_position="top",
annotation_font=dict(size=10, color="rgba(255,255,255,0.5)"))
fig.update_layout(
title=dict(text="Implementation Timeline", font=dict(size=14)),
barmode="overlay",
xaxis=dict(title="Months", tickvals=[0, 3, 6, 12, 24, 36],
ticktext=["Now", "3 mo", "6 mo", "12 mo", "24 mo", "3 yr"],
tickfont=dict(size=10), gridcolor="rgba(255,255,255,0.1)"),
yaxis=dict(tickfont=dict(size=9), autorange="reversed"),
legend=dict(font=dict(size=10), bgcolor="rgba(0,0,0,0.3)",
orientation="h", y=1.05),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
margin=dict(t=60, b=50, l=310, r=20),
height=max(380, len(sorted_actions) * 36),
)
st.plotly_chart(fig, use_container_width=True)
def _render_action_count_bars(action_data: list[dict]) -> None:
import plotly.graph_objects as go
from collections import Counter
if not action_data:
return
counts = Counter(a["horizon"] for a in action_data)
horizons = ["QuickWin", "MediumTerm", "Strategic"]
fig = go.Figure(go.Bar(
x=[_HORIZON_LABEL.get(h, h) for h in horizons],
y=[counts.get(h, 0) for h in horizons],
marker_color=[_HORIZON_COLOR[h] for h in horizons],
marker_line_width=0,
text=[counts.get(h, 0) for h in horizons],
textposition="outside", textfont=dict(size=13),
))
fig.update_layout(
title=dict(text="Actions by Horizon", font=dict(size=13)),
yaxis=dict(title="# Actions", gridcolor="rgba(255,255,255,0.1)"),
paper_bgcolor="rgba(0,0,0,0)",
plot_bgcolor="rgba(0,0,0,0)",
margin=dict(t=40, b=80, l=40, r=20),
height=270,
showlegend=False,
xaxis=dict(tickfont=dict(size=9)),
)
st.plotly_chart(fig, use_container_width=True)
# ══════════════════════════════════════════════════════════════════════════════
# UI helpers
# ══════════════════════════════════════════════════════════════════════════════
def _card(content: str, border_color: str = "#2ECC71") -> None:
st.markdown(
f'<div style="background:rgba(255,255,255,0.03);border-left:4px solid {border_color};'
f'border-radius:0 12px 12px 0;padding:1.2rem 1.4rem;margin-bottom:1rem;'
f'font-size:0.88rem;line-height:1.75;white-space:pre-wrap;color:#FAF7F0;">'
f'{content}</div>',
unsafe_allow_html=True,
)
def _data_coverage_bar(
has_energy: bool, has_water: bool, has_waste: bool,
has_rag: bool, has_manual: bool,
) -> None:
items = [
("Energy", has_energy), ("Water", has_water), ("Waste", has_waste),
("RAG Docs", has_rag), ("Manual", has_manual),
]
cols = st.columns(len(items))
for col, (label, present) in zip(cols, items):
color = "#2ECC71" if present else "#E74C3C"
status = "Ready" if present else "Missing"
col.markdown(
f'<div style="background:{color}18;border:1px solid {color}44;'
f'border-radius:8px;padding:0.5rem;text-align:center;">'
f'<div style="font-size:0.8rem;color:{color};">{label}</div>'
f'<div style="font-size:0.7rem;color:{color}88;">{status}</div></div>',
unsafe_allow_html=True,
)
def _section_header(icon: str, title: str, subtitle: str = "") -> None:
st.markdown(
f'<div style="margin:1.4rem 0 0.5rem;">'
f'<span style="font-size:1.2rem;">{icon}</span> '
f'<span style="font-size:1.05rem;font-weight:600;color:#FAF7F0;">{title}</span>'
+ (f'<div style="font-size:0.78rem;color:#A8D5BA;margin-top:0.15rem;">{subtitle}</div>'
if subtitle else "")
+ "</div>",
unsafe_allow_html=True,
)
# ══════════════════════════════════════════════════════════════════════════════
# Main render
# ══════════════════════════════════════════════════════════════════════════════
def render_peer_benchmarking() -> None:
st.markdown("# Peer Institution Benchmarking")
st.markdown(
"Upload sustainability reports from peer institutions. "
"The AI compares them against SPJIMR's data, identifies gaps, "
"ranks all institutions, and generates a targeted action plan "
"backed by interactive charts."
)
if not st.session_state.get("hf_token"):
st.error("Please enter your HuggingFace token in the sidebar.")
return
for key, default in [
("peer_institutions", {}),
("peer_summaries", {}),
("bench_spjimr_manual", ""),
]:
if key not in st.session_state:
st.session_state[key] = default
peers: dict = st.session_state["peer_institutions"]
summaries: dict = st.session_state["peer_summaries"]
# ══════════════════════════════════════════════════════════════════════════
# STEP 1 β€” Upload
# ══════════════════════════════════════════════════════════════════════════
st.markdown("---")
st.markdown("## Step 1 β€” Upload Peer Institution Reports")
col_up, col_list = st.columns([2, 1], gap="large")
with col_up:
uploaded = st.file_uploader(
"Upload sustainability reports",
type=[f.lstrip(".") for f in sorted(SUPPORTED_FORMATS)],
accept_multiple_files=True,
key="peer_uploader",
help="PDF, DOCX, TXT, CSV, XLSX. One file per institution.",
)
inst_name_input = st.text_input(
"Institution name (leave blank to use filename)",
placeholder="e.g. IIM Ahmedabad",
key="peer_inst_name",
)
if st.button("Add Institution(s)", use_container_width=True, key="add_peers"):
if not uploaded:
st.warning("Please upload at least one file first.")
else:
for uf in uploaded:
name = (
inst_name_input.strip()
if inst_name_input.strip() and len(uploaded) == 1
else Path(uf.name).stem.replace("_", " ").replace("-", " ").title()
)
with st.spinner(f"Parsing {uf.name}..."):
chunks = parse_peer_report(uf, institution_name=name)
if chunks:
peers[name] = {"chunks": chunks, "file": uf.name}
for k in ["bench_gap_report", "bench_gap_data",
"bench_ranking", "bench_ranking_scores",
"bench_action_plan", "bench_action_data"]:
st.session_state.pop(k, None)
summaries.pop(name, None)
st.success(f"{name} β€” {len(chunks)} chunks indexed")
else:
st.error(f"Could not extract text from {uf.name}")
with col_list:
st.markdown("##### Loaded Institutions")
if not peers:
st.info("No peer institutions loaded yet.")
else:
for name, meta in list(peers.items()):
c1, c2 = st.columns([3, 1])
c1.markdown(
f"**{name}** \n"
f'<span style="font-size:0.72rem;color:#A8D5BA;">'
f'{meta["file"]} Β· {len(meta["chunks"])} chunks</span>',
unsafe_allow_html=True,
)
if c2.button("X", key=f"del_{name}", help=f"Remove {name}"):
del peers[name]
summaries.pop(name, None)
for k in ["bench_gap_report", "bench_gap_data",
"bench_ranking", "bench_ranking_scores",
"bench_action_plan", "bench_action_data"]:
st.session_state.pop(k, None)
st.rerun()
if not peers:
st.info("Upload at least one peer institution report to continue.")
return
# ══════════════════════════════════════════════════════════════════════════
# STEP 2 β€” SPJIMR context
# ══════════════════════════════════════════════════════════════════════════
st.markdown("---")
st.markdown("## Step 2 β€” SPJIMR Current State")
has_energy = st.session_state.get("energy_df") is not None
has_water = st.session_state.get("water_df") is not None
has_waste = any(st.session_state.get(k) is not None
for k in ["waste_full", "waste_all_df", "waste_df"])
has_rag = (st.session_state.get("consultant") is not None
and st.session_state["consultant"].is_ready)
has_manual = bool(st.session_state.get("bench_spjimr_manual", "").strip())
_data_coverage_bar(has_energy, has_water, has_waste, has_rag, has_manual)
st.markdown("")
if not any([has_energy, has_water, has_waste, has_rag, has_manual]):
st.warning(
"No SPJIMR data found. Use the manual context panel below to "
"describe SPJIMR's sustainability performance."
)
with st.expander(
"Add / Edit Manual SPJIMR Context",
expanded=not any([has_energy, has_water, has_waste, has_rag]),
):
manual_text = st.text_area(
"SPJIMR context",
value=st.session_state.get("bench_spjimr_manual", ""),
height=200,
placeholder=(
"Renewable energy: 42% of total electricity\n"
"Total energy FY24: 1,850,000 kWh\n"
"Water FY24: 28,500 kL; rainwater harvesting: 12%\n"
"Waste recovery: 61%\n"
"GRI/BRSR disclosure: None\n"
"Net-zero target: Not declared"
),
label_visibility="collapsed",
key="manual_ctx_input",
)
if st.button("Save Manual Context", key="save_manual"):
st.session_state["bench_spjimr_manual"] = manual_text.strip()
for k in ["bench_gap_report", "bench_gap_data",
"bench_ranking", "bench_ranking_scores",
"bench_action_plan", "bench_action_data"]:
st.session_state.pop(k, None)
st.success("Manual context saved.")
with st.expander("Preview SPJIMR context sent to AI"):
st.code(_spjimr_context_summary(), language="text")
# ══════════════════════════════════════════════════════════════════════════
# STEP 3 β€” Run analysis
# ══════════════════════════════════════════════════════════════════════════
st.markdown("---")
st.markdown("## Step 3 β€” Run Benchmarking Analysis")
run_all = st.button(
"Run Full Benchmarking Analysis",
use_container_width=True, key="run_all_bench", type="primary",
)
c1, c2, c3, c4 = st.columns(4)
run_summaries = c1.button("1 Peer Summaries", key="run_sum", use_container_width=True)
run_gaps = c2.button("2 Gap Analysis", key="run_gaps", use_container_width=True)
run_ranking = c3.button("3 Ranking", key="run_rank", use_container_width=True)
run_actions = c4.button("4 Action Plan", key="run_act", use_container_width=True)
spjimr_ctx = _spjimr_context_summary()
if run_all or run_summaries:
prog = st.progress(0)
for i, (name, meta) in enumerate(peers.items()):
with st.spinner(f"Summarising {name}..."):
summaries[name] = _institution_summary(name, meta["chunks"])
prog.progress(int((i + 1) / len(peers) * 100))
st.session_state["peer_summaries"] = summaries
st.success(f"Summaries generated for {len(summaries)} institution(s)")
if run_all or run_gaps:
if not summaries:
st.warning("Run Stage 1 (Peer Summaries) first.")
else:
with st.spinner("Analysing gaps..."):
gap_text = _gap_analysis(spjimr_ctx, summaries)
st.session_state["bench_gap_report"] = gap_text
st.session_state["bench_gap_data"] = _parse_gap_data(gap_text)
st.success("Gap analysis complete")
if run_all or run_ranking:
if not summaries:
st.warning("Run Stage 1 (Peer Summaries) first.")
else:
with st.spinner("Ranking institutions..."):
ranking_text = _rank_institutions(spjimr_ctx, summaries)
all_names = ["SPJIMR"] + list(summaries.keys())
scores = _parse_scores(ranking_text) or _fallback_scores(ranking_text, all_names)
st.session_state["bench_ranking"] = ranking_text
st.session_state["bench_ranking_scores"] = scores
st.success("Ranking complete")
if run_all or run_actions:
gap = st.session_state.get("bench_gap_report", "")
rank = st.session_state.get("bench_ranking", "")
if not (gap and rank):
st.warning("Run Gap Analysis and Ranking first.")
else:
with st.spinner("Building action plan..."):
action_text = _action_plan(spjimr_ctx, gap, rank)
st.session_state["bench_action_plan"] = action_text
st.session_state["bench_action_data"] = _parse_action_data(action_text)
st.success("Action plan ready")
# ══════════════════════════════════════════════════════════════════════════
# STEP 4 β€” Results
# ══════════════════════════════════════════════════════════════════════════
has_results = any(
st.session_state.get(k)
for k in ["peer_summaries", "bench_gap_report", "bench_ranking", "bench_action_plan"]
)
if not has_results:
return
st.markdown("---")
st.markdown("## Results")
tab_sum, tab_gap, tab_rank, tab_plan = st.tabs([
"Peer Summaries",
"Gap Analysis",
"Ranking",
"Action Plan",
])
# ── Tab 1: Peer Summaries ─────────────────────────────────────────────────
with tab_sum:
if not summaries:
st.info("Run Stage 1 to generate peer summaries.")
else:
_section_header("", "Disclosure Coverage Matrix",
"Which institutions report which sustainability dimensions")
try:
_render_disclosure_heatmap(summaries, spjimr_ctx)
except Exception as exc:
logger.warning("Disclosure heatmap failed: %s", exc)
st.info("Heatmap unavailable β€” see summaries below.")
st.markdown("---")
_section_header("", "Full Institution Summaries")
for name, summary in summaries.items():
with st.expander(f"{name}", expanded=False):
_card(summary, border_color="#3498DB")
# ── Tab 2: Gap Analysis ───────────────────────────────────────────────────
with tab_gap:
gap_report = st.session_state.get("bench_gap_report")
if not gap_report:
st.info("Run Stage 2 to generate the gap analysis.")
else:
gap_data = st.session_state.get("bench_gap_data", [])
_section_header("", "Gap Summary")
_render_gap_summary_metrics(gap_data)
st.markdown("")
ch_left, ch_right = st.columns([3, 2], gap="large")
with ch_left:
_section_header("", "SPJIMR vs Best Peer per Metric")
try:
_render_gap_bars(gap_data)
except Exception as exc:
logger.warning("Gap bar chart failed: %s", exc)
with ch_right:
_section_header("", "Gap Severity Distribution")
try:
_render_gap_severity_donut(gap_data)
except Exception as exc:
logger.warning("Gap donut failed: %s", exc)
st.markdown("---")
_section_header("", "Detailed Gap Analysis")
for section in gap_report.split("##"):
s = section.strip()
if not s:
continue
clean = "\n".join(
ln for ln in s.splitlines()
if not ln.strip().upper().startswith("GAPDATA::")
)
if not clean.strip():
continue
if "Critical" in clean[:40]: _card("## " + clean, "#E74C3C")
elif "Moderate" in clean[:40]: _card("## " + clean, "#F39C12")
elif "Leads" in clean[:40]: _card("## " + clean, "#2ECC71")
elif "Strength" in clean[:40]: _card("## " + clean, "#2ECC71")
else: _card("## " + clean, "#95A5A6")
st.download_button(
"Download Gap Analysis", data=gap_report,
file_name="spjimr_gap_analysis.txt", mime="text/plain",
use_container_width=True,
)
# ── Tab 3: Ranking ────────────────────────────────────────────────────────
with tab_rank:
ranking = st.session_state.get("bench_ranking")
if not ranking:
st.info("Run Stage 3 to generate the ranking.")
else:
scores = st.session_state.get("bench_ranking_scores", {})
if scores:
_section_header("", "Overall Score Ranking")
col_pod, col_tbl = st.columns([3, 2], gap="large")
with col_pod:
try: _render_podium(scores)
except Exception as exc: logger.warning("Podium failed: %s", exc)
with col_tbl:
st.markdown("##### Score Breakdown")
try: _render_score_table(scores)
except Exception as exc: logger.warning("Score table failed: %s", exc)
st.markdown("---")
_section_header("", "Dimension Comparison",
"Radar chart (left) and grouped bar chart (right)")
col_rad, col_bar = st.columns(2, gap="large")
with col_rad:
try: _render_radar_chart(scores)
except Exception as exc: logger.warning("Radar failed: %s", exc)
with col_bar:
try: _render_grouped_bar(scores)
except Exception as exc: logger.warning("Grouped bar failed: %s", exc)
# Dimension legend pills
st.markdown("")
dim_cols = st.columns(len(RANK_DIMENSIONS))
for col, dim, color in zip(dim_cols, RANK_DIMENSIONS, DIM_COLORS):
col.markdown(
f'<div style="background:{color}18;border:1px solid {color}44;'
f'border-radius:8px;padding:0.5rem;text-align:center;'
f'font-size:0.72rem;color:{color};">{dim}</div>',
unsafe_allow_html=True,
)
else:
st.info("Machine-readable scores not extracted β€” see narrative below.")
st.markdown("")
_section_header("", "Narrative Ranking")
clean_ranking = "\n".join(
ln for ln in ranking.splitlines()
if not ln.strip().upper().startswith("SCORES::")
)
_card(clean_ranking, border_color="#D4AF37")
st.download_button(
"Download Ranking Report", data=clean_ranking,
file_name="spjimr_peer_ranking.txt", mime="text/plain",
use_container_width=True,
)
# ── Tab 4: Action Plan ────────────────────────────────────────────────────
with tab_plan:
action_plan = st.session_state.get("bench_action_plan")
if not action_plan:
st.info("Run Stage 4 to generate the action plan.")
else:
action_data = st.session_state.get("bench_action_data", [])
if action_data:
n_qw = sum(1 for a in action_data if a["horizon"] == "QuickWin")
n_mt = sum(1 for a in action_data if a["horizon"] == "MediumTerm")
n_st = sum(1 for a in action_data if a["horizon"] == "Strategic")
km1, km2, km3, km4 = st.columns(4)
km1.metric("Total Actions", len(action_data))
km2.metric("Quick Wins", n_qw)
km3.metric("Medium-Term", n_mt)
km4.metric("Strategic", n_st)
st.markdown("")
_section_header("", "Effort vs Impact Matrix",
"Top-right quadrant = high impact, low effort β€” prioritise these first")
try: _render_effort_impact_chart(action_data)
except Exception as exc: logger.warning("Effort-impact failed: %s", exc)
st.markdown("---")
act_tl, act_cnt = st.columns([3, 1], gap="large")
with act_tl:
_section_header("", "Implementation Timeline")
try: _render_action_timeline(action_data)
except Exception as exc: logger.warning("Timeline failed: %s", exc)
with act_cnt:
_section_header("", "Actions by Horizon")
try: _render_action_count_bars(action_data)
except Exception as exc: logger.warning("Action count bars failed: %s", exc)
st.markdown("---")
else:
st.info("No structured action data extracted β€” charts unavailable for this run.")
_section_header("", "Full Action Plan")
icon_colors = {"Quick": "#2ECC71", "Medium": "#3498DB",
"Strategic": "#D4AF37", "Disclosure": "#9B59B6"}
for section in action_plan.split("##"):
s = section.strip()
if not s:
continue
clean = "\n".join(
ln for ln in s.splitlines()
if not ln.strip().upper().startswith("ACTIONDATA::")
)
if not clean.strip():
continue
color = next(
(c for kw, c in icon_colors.items() if kw.lower() in clean[:40].lower()),
"#A8D5BA",
)
_card("## " + clean, border_color=color)
st.download_button(
"Download Action Plan", data=action_plan,
file_name="spjimr_action_plan.txt", mime="text/plain",
use_container_width=True,
)
# ── Consolidated report download ──────────────────────────────────────────
gap = st.session_state.get("bench_gap_report", "")
rank = st.session_state.get("bench_ranking", "")
plan = st.session_state.get("bench_action_plan","")
if gap and rank and plan:
clean_rank = "\n".join(
ln for ln in rank.splitlines()
if not ln.strip().upper().startswith("SCORES::")
)
st.markdown("---")
full_report = "\n\n".join([
"SPJIMR PEER BENCHMARKING REPORT",
"=" * 60,
"SECTION 1: PEER INSTITUTION SUMMARIES",
"\n\n".join(f"{n}\n{'-'*40}\n{s}" for n, s in summaries.items()),
"=" * 60,
"SECTION 2: GAP ANALYSIS",
"\n".join(ln for ln in gap.splitlines()
if not ln.strip().upper().startswith("GAPDATA::")),
"=" * 60,
"SECTION 3: INSTITUTION RANKING",
clean_rank,
"=" * 60,
"SECTION 4: SPJIMR ACTION PLAN",
"\n".join(ln for ln in plan.splitlines()
if not ln.strip().upper().startswith("ACTIONDATA::")),
])
st.download_button(
"Download Full Benchmarking Report",
data=full_report,
file_name="spjimr_full_benchmarking_report.txt",
mime="text/plain",
use_container_width=True,
)