Spaces:
Sleeping
Sleeping
| """ | |
| 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, | |
| ) |