Spaces:
Running
Running
| """ | |
| app.py — SPJIMR Strategic ESG Consultant | |
| ========================================= | |
| Streamlit multi-page application with SPJIMR brand theme. | |
| Colours: #F67D31 (orange) · #500073 (purple) | |
| Navigation: | |
| 📤 Data Ingestion → upload & process documents | |
| 📊 ESG Dashboard → automated charts (Energy / Water / Waste) | |
| 🤖 AI Consultant → RAG-powered Q&A | |
| 🎨 Creative Studio → marketing prompt generator | |
| 📝 Data Entry → daily waste entry form | |
| ♻️ Waste Analytics → block-wise & multi-month waste dashboard | |
| 🏆 Gamification → monthly leaderboard & scoring | |
| 🏫 Peer Benchmarking→ institution comparison | |
| Run: streamlit run app.py | |
| """ | |
| import logging | |
| import os | |
| import textwrap | |
| import re | |
| import sys | |
| from pathlib import Path | |
| from pages.waste_analytics import render_waste_analytics | |
| from pages.gamification import render_gamification | |
| from pages.data_entry import render_data_entry | |
| from pages.peer_benchmarking import render_peer_benchmarking | |
| import streamlit as st | |
| ROOT = Path(__file__).parent | |
| if str(ROOT) not in sys.path: | |
| sys.path.insert(0, str(ROOT)) | |
| _IS_HF = os.getenv("SPACE_ID") is not None | |
| _DATA_ROOT = Path("/tmp") if _IS_HF else Path("data") | |
| for _d in [_DATA_ROOT / "uploads", _DATA_ROOT / "faiss_index"]: | |
| _d.mkdir(parents=True, exist_ok=True) | |
| from core.processor import (DocumentProcessor, extract_waste_series, | |
| extract_energy_series, extract_spjimr_metrics_raw) | |
| from core.consultant import ESGConsultant | |
| logging.basicConfig(level=logging.INFO) | |
| logger = logging.getLogger(__name__) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # Page Config | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| st.set_page_config( | |
| page_title="SPJIMR ESG Consultant", | |
| page_icon="🌿", | |
| layout="wide", | |
| initial_sidebar_state="expanded", | |
| ) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # Brand CSS — SPJIMR Orange #F67D31 · Purple #500073 | |
| # Applied ONLY when logged in to prevent overriding the login page light theme | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| SPJIMR_CSS = """ | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@300;400;600&family=DM+Sans:wght@300;400;500&display=swap'); | |
| /* ── Root Variables ── */ | |
| :root { | |
| --orange: #F67D31; | |
| --orange-light: #FFA066; | |
| --orange-pale: rgba(246,125,49,0.12); | |
| --purple: #500073; | |
| --purple-deep: #1C0029; | |
| --purple-mid: #350050; | |
| --purple-light: #6b009a; | |
| --cream: #FDF5FF; | |
| --muted: #C4A4D4; | |
| --glass-bg: rgba(255,255,255,0.04); | |
| --border-o: rgba(246,125,49,0.22); | |
| --border-p: rgba(80,0,115,0.35); | |
| } | |
| /* ── Global (dark theme — logged-in only) ── */ | |
| html, body, [class*="css"] { | |
| font-family: 'DM Sans', sans-serif !important; | |
| background-color: var(--purple-deep) !important; | |
| color: var(--cream) !important; | |
| } | |
| /* ── Scrollbar ── */ | |
| ::-webkit-scrollbar { width: 6px; } | |
| ::-webkit-scrollbar-track { background: var(--purple-deep); } | |
| ::-webkit-scrollbar-thumb { background: var(--purple-mid); border-radius: 3px; } | |
| /* ── Sidebar ── */ | |
| [data-testid="stSidebar"] { | |
| background: linear-gradient(180deg, #110018 0%, #1C0029 100%) !important; | |
| border-right: 1px solid var(--border-p) !important; | |
| } | |
| [data-testid="stSidebar"] .stSelectbox label, | |
| [data-testid="stSidebar"] .stTextInput label, | |
| [data-testid="stSidebar"] p { | |
| color: var(--muted) !important; | |
| font-size: 0.78rem; | |
| } | |
| [data-testid="stSidebar"] .stRadio label { color: var(--cream) !important; font-size: 0.85rem; } | |
| [data-testid="stSidebar"] .stRadio [data-testid="stMarkdownContainer"] p { color: var(--cream) !important; } | |
| /* ── Headings ── */ | |
| h1 { font-family: 'Cormorant Garamond', serif !important; font-weight: 600 !important; | |
| color: var(--orange) !important; letter-spacing: -0.3px; } | |
| h2 { font-weight: 600 !important; color: var(--orange-light) !important; } | |
| h3 { color: var(--muted) !important; font-size: 0.88rem !important; | |
| text-transform: uppercase; letter-spacing: 1px; } | |
| /* ── Cards ── */ | |
| .esg-card { | |
| background: var(--glass-bg); | |
| border: 1px solid var(--border-o); | |
| border-radius: 12px; | |
| padding: 1.4rem 1.6rem; | |
| margin-bottom: 1rem; | |
| } | |
| .esg-card h4 { color: var(--orange); font-size: 0.78rem; text-transform: uppercase; margin: 0 0 0.3rem; } | |
| .esg-card .big-num { font-size: 2.2rem; font-weight: 700; color: var(--orange-light); line-height: 1; } | |
| .esg-card .sub { color: var(--muted); font-size: 0.75rem; margin-top: 0.2rem; } | |
| /* ── Answer / AI response box ── */ | |
| .answer-box { | |
| background: rgba(53,0,80,0.5); | |
| border-left: 4px solid var(--orange); | |
| border-radius: 0 12px 12px 0; | |
| padding: 1.2rem 1.4rem; | |
| font-size: 0.95rem; | |
| line-height: 1.75; | |
| white-space: pre-wrap; | |
| color: var(--cream); | |
| } | |
| /* ── Creative prompt cards ── */ | |
| .prompt-card { | |
| background: rgba(246,125,49,0.06); | |
| border: 1px solid rgba(246,125,49,0.28); | |
| border-radius: 12px; | |
| padding: 1.2rem 1.4rem; | |
| margin-bottom: 0.8rem; | |
| font-size: 0.82rem; | |
| line-height: 1.7; | |
| color: var(--cream); | |
| } | |
| /* ── Buttons ── */ | |
| .stButton > button { | |
| background: linear-gradient(135deg, var(--purple-mid), var(--purple-deep)) !important; | |
| color: var(--orange) !important; | |
| border: 1px solid var(--orange) !important; | |
| border-radius: 8px !important; | |
| font-size: 0.82rem !important; | |
| letter-spacing: 0.5px; | |
| padding: 0.55rem 1.4rem !important; | |
| transition: all 0.2s ease; | |
| font-family: 'DM Sans', sans-serif !important; | |
| } | |
| .stButton > button:hover { | |
| background: var(--purple-mid) !important; | |
| color: white !important; | |
| border-color: var(--orange-light) !important; | |
| box-shadow: 0 0 14px rgba(246,125,49,0.25) !important; | |
| } | |
| /* Primary button */ | |
| .stButton > button[kind="primary"] { | |
| background: var(--purple) !important; | |
| color: white !important; | |
| border-color: var(--orange) !important; | |
| } | |
| .stButton > button[kind="primary"]:hover { | |
| background: var(--purple-light) !important; | |
| box-shadow: 0 0 14px rgba(246,125,49,0.3) !important; | |
| } | |
| /* ── Inputs ── */ | |
| .stTextArea textarea, .stTextInput input { | |
| background: rgba(255,255,255,0.04) !important; | |
| border: 1px solid var(--border-p) !important; | |
| color: var(--cream) !important; | |
| border-radius: 8px !important; | |
| font-family: 'DM Sans', sans-serif !important; | |
| } | |
| .stTextArea textarea:focus, .stTextInput input:focus { | |
| border-color: var(--orange) !important; | |
| box-shadow: 0 0 0 3px rgba(246,125,49,0.12) !important; | |
| } | |
| .stSelectbox > div > div { | |
| background: rgba(255,255,255,0.04) !important; | |
| border: 1px solid var(--border-p) !important; | |
| color: var(--cream) !important; | |
| } | |
| /* ── File uploader ── */ | |
| [data-testid="stFileUploader"] { | |
| background: rgba(255,255,255,0.03); | |
| border: 2px dashed var(--border-o) !important; | |
| border-radius: 12px; | |
| } | |
| /* ── Metric tiles ── */ | |
| [data-testid="stMetric"] { | |
| background: var(--glass-bg) !important; | |
| border: 1px solid var(--border-o) !important; | |
| border-radius: 10px; | |
| padding: 0.8rem 1rem; | |
| } | |
| [data-testid="stMetricLabel"] { color: var(--muted) !important; font-size: 0.75rem; } | |
| [data-testid="stMetricValue"] { color: var(--orange-light) !important; } | |
| [data-testid="stMetricDelta"] { color: #a8e6c0 !important; } | |
| /* ── Divider ── */ | |
| hr { border-color: var(--border-p) !important; } | |
| /* ── Expander ── */ | |
| .stExpander { border: 1px solid var(--border-p) !important; border-radius: 8px !important; } | |
| .stExpander > summary { color: var(--muted) !important; font-size: 0.78rem; } | |
| /* ── Tabs ── */ | |
| [data-testid="stTabs"] button { font-size: 0.78rem !important; color: var(--muted) !important; } | |
| [data-testid="stTabs"] [aria-selected="true"] { | |
| color: var(--orange) !important; | |
| border-bottom-color: var(--orange) !important; | |
| } | |
| /* ── Progress bar ── */ | |
| .stProgress > div > div { background: var(--orange) !important; } | |
| /* ── Alerts ── */ | |
| .stAlert { border-radius: 8px !important; } | |
| /* ── Slider ── */ | |
| [data-testid="stSlider"] [data-baseweb="slider"] [data-testid="stThumbValue"] { | |
| color: var(--orange) !important; | |
| } | |
| </style> | |
| """ | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # Login Page CSS — light theme, injected ONLY on the login page | |
| # Resets Streamlit's default dark overrides so the form panel is visible | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| LOGIN_CSS = """ | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,300;0,400;0,600;1,300&family=DM+Sans:wght@300;400;500&display=swap'); | |
| /* === 1. Full dark background everywhere === */ | |
| html, body, | |
| section[data-testid="stAppViewContainer"], | |
| div[data-testid="stAppViewBlockWrapper"], | |
| div.appview-container, | |
| div.main, | |
| div.block-container, | |
| div[class*="block-container"], | |
| div[class*="stApp"], | |
| div[class*="css"], | |
| div[data-testid="stVerticalBlock"], | |
| div[data-testid="stVerticalBlockBorderWrapper"], | |
| div[data-testid="stForm"], | |
| div[data-testid="column"], | |
| div[data-testid="stHorizontalBlock"] { | |
| background-color: #0D0014 !important; | |
| background-image: none !important; | |
| font-family: 'DM Sans', sans-serif !important; | |
| } | |
| /* === 2. Hide Streamlit chrome === */ | |
| [data-testid="stSidebar"], | |
| [data-testid="stSidebarNav"], | |
| [data-testid="collapsedControl"], | |
| .stDeployButton, | |
| #MainMenu, footer, header, | |
| div[data-testid="stDecoration"], | |
| div[data-testid="stStatusWidget"], | |
| div[data-testid="stToolbar"] { | |
| display: none !important; | |
| visibility: hidden !important; | |
| } | |
| /* === 3. Block container: right half === */ | |
| div.block-container, | |
| div[class*="block-container"] { | |
| margin-left: 48% !important; | |
| margin-right: 0 !important; | |
| max-width: 480px !important; | |
| padding: 0 2.5rem !important; | |
| min-height: 100vh !important; | |
| display: flex !important; | |
| flex-direction: column !important; | |
| justify-content: center !important; | |
| box-sizing: border-box !important; | |
| border-left: 1px solid rgba(246,125,49,0.15) !important; | |
| } | |
| /* === 4. Brand panel: fixed left === */ | |
| .spjimr-brand-panel { | |
| position: fixed !important; | |
| top: 0 !important; left: 0 !important; | |
| width: 48vw !important; | |
| height: 100vh !important; | |
| background: linear-gradient(160deg, #1C0029 0%, #0D0014 55%, #150008 100%) !important; | |
| border-right: 1px solid rgba(246,125,49,0.15) !important; | |
| display: flex !important; | |
| flex-direction: column !important; | |
| justify-content: space-between !important; | |
| padding: clamp(2rem,3.5vw,3.5rem) clamp(1.5rem,2.5vw,3rem) !important; | |
| overflow: hidden !important; | |
| z-index: 9999 !important; | |
| box-sizing: border-box !important; | |
| } | |
| /* === 5. All text: light on dark === */ | |
| p, span, div, label { color: #e8d5f5 !important; } | |
| a { color: #F67D31 !important; font-weight: 500 !important; text-decoration: none !important; } | |
| a:hover { color: #FFA066 !important; } | |
| /* === 6. Input labels === */ | |
| .stTextInput > label, | |
| .stTextInput > label p, | |
| .stTextInput label span { | |
| font-size: 10px !important; | |
| font-weight: 600 !important; | |
| letter-spacing: 0.1em !important; | |
| text-transform: uppercase !important; | |
| color: #c4a4d4 !important; | |
| display: block !important; | |
| margin-bottom: 6px !important; | |
| } | |
| /* === 7. Input fields === */ | |
| .stTextInput > div > div > input { | |
| height: 50px !important; | |
| border-radius: 10px !important; | |
| border: 1px solid rgba(196,164,212,0.25) !important; | |
| background: rgba(255,255,255,0.05) !important; | |
| color: #fdf5ff !important; | |
| font-family: 'DM Sans', sans-serif !important; | |
| font-size: 14px !important; | |
| padding: 0 14px !important; | |
| width: 100% !important; | |
| box-sizing: border-box !important; | |
| transition: border-color 0.2s, box-shadow 0.2s !important; | |
| } | |
| .stTextInput > div > div > input:focus { | |
| border-color: #F67D31 !important; | |
| box-shadow: 0 0 0 3px rgba(246,125,49,0.18) !important; | |
| outline: none !important; | |
| background: rgba(255,255,255,0.07) !important; | |
| } | |
| .stTextInput > div > div > input::placeholder { color: rgba(196,164,212,0.5) !important; } | |
| /* Password toggle icon */ | |
| .stTextInput button { background: transparent !important; border: none !important; color: #c4a4d4 !important; } | |
| /* === 8. Checkbox === */ | |
| .stCheckbox label p { | |
| color: #c4a4d4 !important; | |
| font-size: 13px !important; | |
| text-transform: none !important; | |
| letter-spacing: 0 !important; | |
| } | |
| .stCheckbox input[type="checkbox"] { accent-color: #F67D31 !important; } | |
| /* Dark checkbox background */ | |
| .stCheckbox > label { background: transparent !important; } | |
| /* === 9. Sign In button — full width orange/purple gradient === */ | |
| div[data-testid="stFormSubmitButton"] > button { | |
| width: 100% !important; | |
| height: 52px !important; | |
| background: linear-gradient(135deg, #F67D31 0%, #c45a00 100%) !important; | |
| color: #ffffff !important; | |
| border: none !important; | |
| border-radius: 10px !important; | |
| font-family: 'DM Sans', sans-serif !important; | |
| font-size: 15px !important; | |
| font-weight: 500 !important; | |
| letter-spacing: 0.04em !important; | |
| margin-top: 0.5rem !important; | |
| box-shadow: 0 4px 20px rgba(246,125,49,0.35) !important; | |
| cursor: pointer !important; | |
| transition: all 0.2s !important; | |
| } | |
| div[data-testid="stFormSubmitButton"] > button:hover { | |
| background: linear-gradient(135deg, #FFA066 0%, #F67D31 100%) !important; | |
| box-shadow: 0 6px 24px rgba(246,125,49,0.5) !important; | |
| transform: translateY(-1px) !important; | |
| } | |
| /* === 10. SSO button === */ | |
| .stButton > button { | |
| width: 100% !important; | |
| height: 50px !important; | |
| background: rgba(255,255,255,0.05) !important; | |
| color: #fdf5ff !important; | |
| border: 1px solid rgba(196,164,212,0.25) !important; | |
| border-radius: 10px !important; | |
| font-family: 'DM Sans', sans-serif !important; | |
| font-size: 14px !important; | |
| font-weight: 400 !important; | |
| transition: all 0.2s !important; | |
| } | |
| .stButton > button:hover { | |
| border-color: #F67D31 !important; | |
| background: rgba(246,125,49,0.08) !important; | |
| color: #fdf5ff !important; | |
| box-shadow: 0 0 14px rgba(246,125,49,0.2) !important; | |
| } | |
| /* === 11. Divider line text === */ | |
| hr { border-color: rgba(196,164,212,0.15) !important; } | |
| .stAlert { border-radius: 10px !important; } | |
| /* === 12. Mobile === */ | |
| @media (max-width: 800px) { | |
| .spjimr-brand-panel { | |
| position: relative !important; | |
| width: 100% !important; height: auto !important; | |
| flex-direction: row !important; align-items: center !important; | |
| padding: 1.2rem 1.5rem !important; | |
| border-right: none !important; | |
| border-bottom: 1px solid rgba(246,125,49,0.2) !important; | |
| z-index: 100 !important; | |
| } | |
| div.block-container, div[class*="block-container"] { | |
| margin-left: 0 !important; | |
| width: 100% !important; | |
| max-width: 100% !important; | |
| padding: 2rem 1.5rem !important; | |
| min-height: auto !important; | |
| border-left: none !important; | |
| } | |
| } | |
| </style> | |
| """ | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # Session-state initialisation | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def _init_state(): | |
| defaults = { | |
| "logged_in": False, | |
| "login_user": "", | |
| "hf_token": os.getenv("HF_TOKEN", ""), | |
| "consultant": None, | |
| "processed_docs": [], | |
| "waste_df": None, | |
| "waste_full": None, | |
| "energy_df": None, | |
| "energy_full": None, | |
| "water_df": None, | |
| } | |
| for k, v in defaults.items(): | |
| if k not in st.session_state: | |
| st.session_state[k] = v | |
| _init_state() | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # Chart layout shared across all plotly charts | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| _PLOT_LAYOUT = dict( | |
| paper_bgcolor="rgba(0,0,0,0)", | |
| plot_bgcolor="rgba(0,0,0,0)", | |
| font=dict(color="#C4A4D4", family="DM Sans"), | |
| legend=dict(bgcolor="rgba(0,0,0,0)"), | |
| xaxis=dict(gridcolor="rgba(255,255,255,0.06)", tickangle=30), | |
| yaxis=dict(gridcolor="rgba(255,255,255,0.06)"), | |
| margin=dict(l=0, r=0, t=45, b=0), | |
| ) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # Login Page | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def render_login(): | |
| """SPJIMR login — light right panel, dark left brand panel.""" | |
| st.markdown(LOGIN_CSS, unsafe_allow_html=True) | |
| # ── Brand panel (pure HTML, fixed left) ────────────────────────────────── | |
| st.markdown(""" | |
| <div class="spjimr-brand-panel"> | |
| <svg style="position:absolute;bottom:-40px;left:-10px;width:320px;height:500px;opacity:0.07;pointer-events:none;" viewBox="0 0 320 500" fill="none"> | |
| <path d="M10 500 Q50 290 90 70" stroke="#F67D31" stroke-width="28" stroke-linecap="round"/> | |
| <path d="M65 500 Q115 290 155 50" stroke="#F67D31" stroke-width="22" stroke-linecap="round"/> | |
| <path d="M125 500 Q185 300 225 40" stroke="#F67D31" stroke-width="16" stroke-linecap="round"/> | |
| <path d="M190 500 Q248 320 292 70" stroke="#F67D31" stroke-width="10" stroke-linecap="round"/> | |
| </svg> | |
| <div style="position:absolute;bottom:0;left:0;width:400px;height:400px;background:radial-gradient(ellipse at 20% 100%,rgba(246,125,49,0.13) 0%,transparent 60%);pointer-events:none;"></div> | |
| <div style="position:relative;z-index:2;"> | |
| <div style="display:flex;align-items:center;gap:14px;"> | |
| <svg width="40" height="48" viewBox="0 0 46 54" fill="none"> | |
| <path d="M5 54 Q10 35 18 15 Q22 4 28 0 Q23 15 30 23 Q32 11 40 3 Q38 19 42 27 Q46 36 42 46 Q38 54 30 54" fill="none" stroke="#F67D31" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round"/> | |
| <path d="M9 54 Q14 37 22 22 Q26 11 32 5 Q28 19 34 27 Q36 17 42 9" fill="none" stroke="#F67D31" stroke-width="2.5" stroke-linecap="round" opacity="0.55"/> | |
| <path d="M17 54 Q22 38 28 26 Q30 20 36 14" fill="none" stroke="#F67D31" stroke-width="2" stroke-linecap="round" opacity="0.35"/> | |
| </svg> | |
| <div> | |
| <div style="font-size:11px;font-weight:300;letter-spacing:0.05em;color:rgba(255,255,255,0.55);">Bharatiya Vidya Bhavan's</div> | |
| <div style="font-size:28px;font-weight:500;line-height:1;margin-top:2px;"><span style="color:#F67D31;">SP</span><span style="color:#fff;">JIMR</span></div> | |
| </div> | |
| </div> | |
| <div class="brand-tagline" style="margin-top:2.8rem;"> | |
| <div style="font-family:'Cormorant Garamond',serif;font-size:clamp(1.8rem,2.8vw,3rem);font-weight:300;line-height:1.1;color:#fff;margin-bottom:1.2rem;"> | |
| Where <em style="font-style:italic;color:#FFA066;">purpose</em><br>meets<br>management. | |
| </div> | |
| <p style="font-size:13px;font-weight:300;line-height:1.8;color:rgba(255,255,255,0.45);max-width:300px;margin:0;"> | |
| S.P. Jain Institute of Management and Research — shaping responsible leaders for a complex world. | |
| </p> | |
| </div> | |
| </div> | |
| <div class="brand-stats" style="position:relative;z-index:2;display:flex;gap:2.5rem;padding-top:2rem;border-top:1px solid rgba(255,255,255,0.1);"> | |
| <div> | |
| <div style="font-family:'Cormorant Garamond',serif;font-size:2rem;font-weight:600;color:#F67D31;line-height:1;">62+</div> | |
| <div style="font-size:10px;color:rgba(255,255,255,0.35);letter-spacing:0.08em;text-transform:uppercase;margin-top:4px;">Years</div> | |
| </div> | |
| <div> | |
| <div style="font-family:'Cormorant Garamond',serif;font-size:2rem;font-weight:600;color:#F67D31;line-height:1;">10K+</div> | |
| <div style="font-size:10px;color:rgba(255,255,255,0.35);letter-spacing:0.08em;text-transform:uppercase;margin-top:4px;">Alumni</div> | |
| </div> | |
| <div> | |
| <div style="font-family:'Cormorant Garamond',serif;font-size:2rem;font-weight:600;color:#F67D31;line-height:1;">#1</div> | |
| <div style="font-size:10px;color:rgba(255,255,255,0.35);letter-spacing:0.08em;text-transform:uppercase;margin-top:4px;">Social impact</div> | |
| </div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ── Form heading ───────────────────────────────────────────────────────── | |
| st.markdown(""" | |
| <div style="padding-top:1.5rem;max-width:420px;"> | |
| <div style="display:inline-flex;align-items:center;gap:7px; | |
| background:rgba(246,125,49,0.12);color:#F67D31; | |
| font-family:'DM Sans',sans-serif;font-size:10px;font-weight:600; | |
| letter-spacing:0.1em;text-transform:uppercase;padding:5px 14px; | |
| border-radius:100px;margin-bottom:1.2rem;border:1px solid rgba(246,125,49,0.3);"> | |
| <span style="width:5px;height:5px;border-radius:50%;background:#F67D31;flex-shrink:0;display:inline-block;"></span> | |
| ESG Sustainability Platform | |
| </div> | |
| <div style="font-family:'Cormorant Garamond',serif;font-size:2.2rem;font-weight:400; | |
| color:#fdf5ff;line-height:1.15;margin-bottom:0.4rem;"> | |
| Welcome back | |
| </div> | |
| <div style="font-family:'DM Sans',sans-serif;font-size:13px;color:#c4a4d4; | |
| font-weight:300;line-height:1.65;margin-bottom:1.8rem;"> | |
| Sign in with your SPJIMR email to continue. | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| err_slot = st.empty() | |
| with st.form("login_form", clear_on_submit=False): | |
| email_val = st.text_input( | |
| "Email address", | |
| placeholder="yourname@spjimr.org", | |
| key="login_email_input", | |
| ) | |
| pass_val = st.text_input( | |
| "Password", | |
| type="password", | |
| placeholder="Enter your password", | |
| key="login_pass_input", | |
| ) | |
| col_r, col_f = st.columns([1, 1]) | |
| with col_r: | |
| st.checkbox("Remember me", key="login_remember") | |
| with col_f: | |
| st.markdown( | |
| '''<div style="text-align:right;padding-top:8px;"> | |
| <a href="#" style="font-size:12px;color:#F67D31;font-weight:500;">Forgot password?</a> | |
| </div>''', | |
| unsafe_allow_html=True, | |
| ) | |
| submitted = st.form_submit_button("Sign in →") | |
| if submitted: | |
| _app_password = os.getenv("APP_PASSWORD") | |
| if not _app_password: | |
| err_slot.error("⚙️ APP_PASSWORD secret is not set. Add it in HuggingFace Space settings.") | |
| elif not email_val or not email_val.lower().endswith("@spjimr.org"): | |
| err_slot.error("Access restricted to SPJIMR email addresses (@spjimr.org).") | |
| elif pass_val != _app_password: | |
| err_slot.error("Incorrect password. Please try again.") | |
| else: | |
| st.session_state["logged_in"] = True | |
| st.session_state["login_user"] = email_val.lower() | |
| st.rerun() | |
| st.markdown(""" | |
| <div style="display:flex;align-items:center;gap:12px;margin:1.4rem 0;"> | |
| <div style="flex:1;height:1px;background:rgba(196,164,212,0.2);"></div> | |
| <span style="font-size:12px;color:#c4a4d4;font-family:'DM Sans',sans-serif;">or continue with</span> | |
| <div style="flex:1;height:1px;background:rgba(196,164,212,0.2);"></div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| if st.button("🔐 SPJIMR Single Sign-On (SSO)", use_container_width=True, key="sso_btn"): | |
| st.session_state["logged_in"] = True | |
| st.session_state["login_user"] = "sso_user@spjimr.org" | |
| st.rerun() | |
| st.markdown(""" | |
| <div style="margin-top:1.6rem;text-align:center;font-family:'DM Sans',sans-serif;font-size:12px;color:#c4a4d4;line-height:2;"> | |
| New to the platform? <a href="#" style="color:#F67D31;font-weight:500;">Request access</a><br> | |
| Having trouble? <a href="#" style="color:#F67D31;font-weight:500;">Contact IT support</a> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # Sidebar (logged-in only) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def render_sidebar() -> str: | |
| with st.sidebar: | |
| # Brand mark | |
| st.markdown(""" | |
| <div style="padding:0.8rem 0 1rem;"> | |
| <div style="font-size:10px;letter-spacing:0.06em;color:#C4A4D4;text-transform:uppercase;margin-bottom:4px;"> | |
| Bharatiya Vidya Bhavan's | |
| </div> | |
| <div style="font-size:24px;font-weight:500;line-height:1;margin-bottom:2px;"> | |
| <span style="color:#F67D31;">SP</span><span style="color:#FDF5FF;">JIMR</span> | |
| </div> | |
| <div style="font-size:11px;color:#C4A4D4;letter-spacing:0.03em;"> | |
| ESG Sustainability Platform | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown("---") | |
| # Logged-in user pill | |
| user = st.session_state.get("login_user", "") | |
| if user: | |
| initials = "".join(p[0].upper() for p in user.split("@")[0].split(".")[:2]) or "U" | |
| st.markdown(f""" | |
| <div style="display:flex;align-items:center;gap:10px; | |
| background:rgba(246,125,49,0.08);border:1px solid rgba(246,125,49,0.2); | |
| border-radius:10px;padding:0.55rem 0.8rem;margin-bottom:0.8rem;"> | |
| <div style="width:32px;height:32px;border-radius:50%;background:#500073; | |
| display:flex;align-items:center;justify-content:center; | |
| font-size:12px;font-weight:600;color:#FFA066;flex-shrink:0;"> | |
| {initials} | |
| </div> | |
| <div> | |
| <div style="font-size:12px;font-weight:500;color:#FDF5FF;">{user.split("@")[0]}</div> | |
| <div style="font-size:10px;color:#C4A4D4;">{user.split("@")[1] if "@" in user else ""}</div> | |
| </div> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| st.markdown("### 🗺 Navigate") | |
| page = st.radio( | |
| "Go to", | |
| [ | |
| "📤 Data Ingestion", | |
| "📊 ESG Dashboard", | |
| "🤖 AI Consultant", | |
| "🎨 Creative Studio", | |
| "📝 Data Entry", | |
| "♻️ Waste Analytics", | |
| "🏆 Gamification", | |
| "🏫 Peer Benchmarking", | |
| ], | |
| label_visibility="collapsed", | |
| ) | |
| st.markdown("---") | |
| # HF Token | |
| st.markdown("### 🔑 Hugging Face API") | |
| _env_token = os.getenv("HF_TOKEN", "") | |
| if _env_token and st.session_state.hf_token == _env_token: | |
| st.success("✅ Token loaded from .env") | |
| hf_override = st.text_input("Override token (optional)", value="", | |
| type="password", placeholder="Leave blank to use .env") | |
| if hf_override.strip(): | |
| st.session_state.hf_token = hf_override.strip() | |
| st.session_state.consultant = None | |
| else: | |
| st.warning("⚠️ No HF_TOKEN in .env") | |
| hf_input = st.text_input("Hugging Face Token", value=st.session_state.hf_token, | |
| type="password", placeholder="hf_...") | |
| if hf_input != st.session_state.hf_token: | |
| st.session_state.hf_token = hf_input | |
| st.session_state.consultant = None | |
| st.markdown("---") | |
| # RAG status | |
| if st.session_state.consultant and st.session_state.consultant.is_ready: | |
| vc = st.session_state.consultant.vector_count | |
| st.success(f"✅ RAG Index: {vc:,} vectors") | |
| else: | |
| st.warning("⚠️ RAG Index: empty") | |
| if st.button("🗑 Reset FAISS Index"): | |
| if st.session_state.consultant: | |
| st.session_state.consultant.reset_index() | |
| for k in ["processed_docs","waste_df","energy_df","water_df","waste_full","energy_full"]: | |
| st.session_state[k] = None if k != "processed_docs" else [] | |
| st.rerun() | |
| st.markdown("---") | |
| # Logout | |
| if st.button("🚪 Sign Out", use_container_width=True): | |
| st.session_state["logged_in"] = False | |
| st.session_state["login_user"] = "" | |
| st.rerun() | |
| st.markdown(""" | |
| <div style="font-size:10px;color:rgba(196,164,212,0.45);line-height:1.6;margin-top:0.5rem;"> | |
| Built for SPJIMR · Powered by Mistral AI + FAISS<br>All data stays local | |
| </div> | |
| """, unsafe_allow_html=True) | |
| return page | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # Consultant factory | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def _get_consultant() -> ESGConsultant: | |
| if st.session_state.consultant is None or not st.session_state.hf_token: | |
| token = st.session_state.hf_token or "NO_TOKEN" | |
| st.session_state.consultant = ESGConsultant(hf_token=token) | |
| return st.session_state.consultant | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # Hero header | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def render_hero(): | |
| user = st.session_state.get("login_user", "") | |
| greeting = f"Welcome back, {user.split('@')[0].replace('.', ' ').title()}" if user else "Strategic ESG Consultant" | |
| st.markdown(f""" | |
| <div style="padding:1.4rem 0 0.6rem; | |
| border-bottom:1px solid rgba(246,125,49,0.18); | |
| margin-bottom:1.5rem;"> | |
| <span style="font-size:0.72rem;color:#F67D31;letter-spacing:2.5px;text-transform:uppercase;font-weight:500;"> | |
| SP Jain Institute of Management & Research | |
| </span> | |
| <h1 style="margin:0.3rem 0 0;font-size:2rem;line-height:1.1; | |
| font-family:'Cormorant Garamond',serif;font-weight:600;color:#F67D31;"> | |
| Strategic ESG Consultant | |
| </h1> | |
| <p style="color:#C4A4D4;font-size:0.85rem;margin-top:0.35rem;"> | |
| {greeting} · Local RAG Pipeline · Qwen2.5 + Phi-3.5 · FAISS · Zero Data Egress | |
| </p> | |
| </div> | |
| """, unsafe_allow_html=True) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # PAGE: Data Ingestion | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def page_ingestion(): | |
| st.markdown("# 📤 Data Ingestion") | |
| st.markdown("Upload campus sustainability files to build the local RAG knowledge base.") | |
| processor = DocumentProcessor(upload_dir=str(_DATA_ROOT / "uploads")) | |
| col1, col2 = st.columns([3, 2], gap="large") | |
| with col1: | |
| st.markdown("### Upload Files") | |
| uploaded_files = st.file_uploader( | |
| "Drag & drop or browse", | |
| type=["csv", "xlsx", "xls", "pdf", "docx", "html", "htm"], | |
| accept_multiple_files=True, | |
| help="Supports: Waste CSV/XLSX, Environmental Metrics XLSX, SPJIMR ESG PDF/DOCX. " | |
| "HTML form exports also supported. " | |
| "For peer institution reports use the 🏫 Peer Benchmarking page.", | |
| ) | |
| st.markdown("### Manual Context / Anomaly Notes") | |
| manual_context = st.text_area( | |
| "Optional: explain data gaps, anomalies, or additional notes", | |
| height=130, | |
| placeholder="e.g. 'The June 2024 dry waste spike was due to a campus renovation project…'", | |
| ) | |
| reset_index = st.checkbox("Reset existing FAISS index before adding new documents", value=False) | |
| vec_count = _get_consultant().vector_count | |
| if vec_count >= 8_000: | |
| st.warning(f"⚠️ FAISS index has **{vec_count:,} vectors** — tick Reset index to avoid duplicates.") | |
| process_btn = st.button("⚙️ Process & Index Documents", use_container_width=True) | |
| with col2: | |
| st.markdown("### Previously Indexed Files") | |
| if st.session_state.processed_docs: | |
| for doc in st.session_state.processed_docs: | |
| fname = Path(doc["filepath"]).name | |
| ext = doc["extension"] | |
| icon = {"csv":"📋","xlsx":"📊","xls":"📊","pdf":"📄","docx":"📝"}.get(ext.lstrip("."), "📁") | |
| st.markdown( | |
| f'<div class="esg-card"><h4>{icon} {fname}</h4>' | |
| f'<div class="sub">{ext.upper()} · Indexed ✓</div></div>', | |
| unsafe_allow_html=True, | |
| ) | |
| else: | |
| st.info("No files indexed yet.") | |
| if process_btn: | |
| if not uploaded_files: | |
| st.warning("Please upload at least one file.") | |
| return | |
| consultant = _get_consultant() | |
| did_reset = False | |
| with st.spinner("Processing documents…"): | |
| for uf in uploaded_files: | |
| try: | |
| saved_path = processor.save_uploaded_file(uf) | |
| result = processor.process(saved_path, manual_context=manual_context) | |
| n_chunks = consultant.index_documents(result["text"], | |
| reset=reset_index and not did_reset) | |
| did_reset = True | |
| if result["dataframes"]: | |
| spjimr = extract_spjimr_metrics_raw(result["filepath"]) | |
| if spjimr.get("waste_series") is not None: | |
| st.session_state.waste_df = spjimr["waste_series"] | |
| st.session_state.waste_full = spjimr.get("waste") | |
| else: | |
| wdf = extract_waste_series(result["dataframes"]) | |
| if wdf is not None: | |
| st.session_state.waste_df = wdf | |
| if spjimr.get("energy_series") is not None: | |
| st.session_state.energy_df = spjimr["energy_series"] | |
| st.session_state.energy_full = spjimr.get("energy") | |
| else: | |
| edf = extract_energy_series(result["dataframes"]) | |
| if edf is not None: | |
| st.session_state.energy_df = edf | |
| if spjimr.get("water") is not None: | |
| st.session_state.water_df = spjimr["water"] | |
| st.session_state.processed_docs.append(result) | |
| st.success(f"✅ **{uf.name}** — {n_chunks} chunks indexed") | |
| except Exception as exc: | |
| st.error(f"❌ Failed to process **{uf.name}**: {exc}") | |
| logger.exception("Processing error for %s", uf.name) | |
| st.balloons() | |
| st.info(f"🧠 FAISS index now holds **{consultant.vector_count:,} vectors**.") | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # PAGE: ESG Dashboard | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def page_dashboard(): | |
| import plotly.graph_objects as go | |
| import pandas as pd | |
| import numpy as np | |
| st.markdown("# 📊 ESG Strategic Dashboard") | |
| has_waste = st.session_state.get("waste_df") is not None | |
| has_energy = st.session_state.get("energy_df") is not None | |
| has_water = st.session_state.get("water_df") is not None | |
| has_waste_full = st.session_state.get("waste_full") is not None | |
| has_energy_full = st.session_state.get("energy_full") is not None | |
| k1, k2, k3, k4, k5 = st.columns(5) | |
| k1.metric("Documents Indexed", str(len(st.session_state.processed_docs))) | |
| k2.metric("RAG Vectors", f"{_get_consultant().vector_count:,}") | |
| k3.metric("Energy Data", "✅" if has_energy else "—") | |
| k4.metric("Water Data", "✅" if has_water else "—") | |
| k5.metric("Waste Data", "✅" if has_waste else "—") | |
| def _hline(fig, y=100, text="100% Target"): | |
| fig.add_hline(y=y, line_dash="dot", line_color="#F67D31", | |
| annotation_text=text, annotation_font=dict(color="#F67D31")) | |
| # ── 1. ENERGY ───────────────────────────────────────────────────────────── | |
| st.markdown("---") | |
| st.markdown("## ⚡ Energy Consumption") | |
| if has_energy_full: | |
| edf = st.session_state.energy_full.copy() | |
| periods = edf["period"].tolist() | |
| ec1, ec2 = st.columns(2, gap="large") | |
| with ec1: | |
| fig_e = go.Figure() | |
| if "solar_kwh" in edf: fig_e.add_trace(go.Bar(x=periods, y=edf["solar_kwh"], name="Solar (kWh)", marker_color="#F67D31")) | |
| if "adani_kwh" in edf: fig_e.add_trace(go.Bar(x=periods, y=edf["adani_kwh"], name="Adani Renewable (kWh)", marker_color="#FFA066")) | |
| if "nonrenewable_kwh" in edf: fig_e.add_trace(go.Bar(x=periods, y=edf["nonrenewable_kwh"],name="Non-Renewable (kWh)", marker_color="#E74C3C", opacity=0.7)) | |
| fig_e.update_layout(**_PLOT_LAYOUT, barmode="stack", height=320, | |
| title=dict(text="Energy by Source (kWh)", font=dict(color="#FFA066"))) | |
| st.plotly_chart(fig_e, use_container_width=True) | |
| with ec2: | |
| edf_clean = st.session_state.energy_df.dropna(subset=["renewable_pct"]) | |
| latest_pct = float(edf_clean["renewable_pct"].iloc[-1]) if not edf_clean.empty else 0 | |
| first_pct = float(edf_clean["renewable_pct"].iloc[0]) if len(edf_clean) > 1 else 0 | |
| fig_g = go.Figure(go.Indicator( | |
| mode="gauge+number+delta", | |
| value=latest_pct, | |
| delta={"reference": first_pct, "suffix": "%"}, | |
| title={"text": "Renewable Mix %", "font": {"color": "#C4A4D4", "size": 13}}, | |
| number={"suffix": "%", "font": {"color": "#FFA066", "size": 36}}, | |
| gauge={ | |
| "axis": {"range": [0, 100], "tickcolor": "#C4A4D4"}, | |
| "bar": {"color": "#F67D31"}, | |
| "bgcolor": "rgba(0,0,0,0)", | |
| "steps": [ | |
| {"range": [0, 33], "color": "rgba(231,76,60,0.12)"}, | |
| {"range": [33, 66], "color": "rgba(246,125,49,0.12)"}, | |
| {"range": [66,100], "color": "rgba(255,160,102,0.12)"}, | |
| ], | |
| "threshold": {"line": {"color": "#F67D31", "width": 3}, "value": 100}, | |
| }, | |
| )) | |
| fig_g.update_layout(paper_bgcolor="rgba(0,0,0,0)", | |
| font=dict(color="#C4A4D4"), height=280, margin=dict(l=20,r=20,t=20,b=0)) | |
| st.plotly_chart(fig_g, use_container_width=True) | |
| if latest_pct >= 100: | |
| st.success("🏆 100% Renewable Energy Achieved!") | |
| else: | |
| st.info(f"🌱 {100 - latest_pct:.1f}% gap to 100% renewable target") | |
| edf_s = st.session_state.energy_df.copy() | |
| fig_el = go.Figure() | |
| fig_el.add_trace(go.Scatter(x=edf_s["period"], y=edf_s["renewable_pct"], | |
| mode="lines+markers", name="Renewable %", | |
| line=dict(color="#F67D31", width=2), marker=dict(size=6))) | |
| _hline(fig_el) | |
| fig_el.update_layout(**_PLOT_LAYOUT, height=260, | |
| title=dict(text="Renewable Energy % Over Time", font=dict(color="#FFA066"))) | |
| fig_el.update_yaxes(gridcolor="rgba(255,255,255,0.06)", title="Renewable %", range=[0, 115]) | |
| st.plotly_chart(fig_el, use_container_width=True) | |
| with st.expander("📋 Energy Data Table"): | |
| st.dataframe(edf, use_container_width=True, hide_index=True) | |
| elif has_energy: | |
| edf_s = st.session_state.energy_df.copy() | |
| fig_el = go.Figure() | |
| fig_el.add_trace(go.Scatter(x=edf_s["period"], y=edf_s["renewable_pct"], | |
| mode="lines+markers", line=dict(color="#F67D31", width=2))) | |
| _hline(fig_el) | |
| fig_el.update_layout(**_PLOT_LAYOUT, height=300, | |
| title=dict(text="Renewable %", font=dict(color="#FFA066"))) | |
| fig_el.update_yaxes(gridcolor="rgba(255,255,255,0.06)", range=[0, 115]) | |
| st.plotly_chart(fig_el, use_container_width=True) | |
| else: | |
| st.info("No energy data detected. Upload your SPJIMR Environmental Metrics XLSX.") | |
| # ── 2. WATER ────────────────────────────────────────────────────────────── | |
| st.markdown("---") | |
| st.markdown("## 💧 Water Consumption") | |
| if has_water: | |
| wdf = st.session_state.water_df.copy() | |
| periods = wdf["period"].tolist() | |
| wc1, wc2 = st.columns(2, gap="large") | |
| with wc1: | |
| fig_w = go.Figure() | |
| if "municipal_kl" in wdf: fig_w.add_trace(go.Bar(x=periods, y=wdf["municipal_kl"], name="Municipal Corporation", marker_color="#3498DB")) | |
| if "tanker_kl" in wdf: fig_w.add_trace(go.Bar(x=periods, y=wdf["tanker_kl"], name="Tanker", marker_color="#E67E22")) | |
| if "rainwater_kl" in wdf: fig_w.add_trace(go.Bar(x=periods, y=wdf["rainwater_kl"], name="Rainwater Harvesting", marker_color="#F67D31")) | |
| fig_w.update_layout(**_PLOT_LAYOUT, barmode="stack", height=320, | |
| title=dict(text="Water by Source (Kilolitres)", font=dict(color="#FFA066"))) | |
| fig_w.update_yaxes(gridcolor="rgba(255,255,255,0.06)", title="kL") | |
| st.plotly_chart(fig_w, use_container_width=True) | |
| with wc2: | |
| src_cols = [c for c in ["municipal_kl","tanker_kl","rainwater_kl"] if c in wdf.columns] | |
| src_totals = [wdf[c].sum() for c in src_cols] | |
| src_labels = [c.replace("_kl","").replace("_"," ").title() for c in src_cols] | |
| fig_wp = go.Figure(go.Pie( | |
| labels=src_labels, values=src_totals, hole=0.5, | |
| marker=dict(colors=["#3498DB","#E67E22","#F67D31"]), | |
| textinfo="label+percent", textfont=dict(size=11), | |
| )) | |
| fig_wp.update_layout( | |
| paper_bgcolor="rgba(0,0,0,0)", font=dict(color="#C4A4D4"), | |
| title=dict(text="Source Mix (Total)", font=dict(color="#FFA066")), | |
| margin=dict(l=0,r=0,t=45,b=0), height=320, showlegend=False) | |
| st.plotly_chart(fig_wp, use_container_width=True) | |
| wk1, wk2, wk3 = st.columns(3) | |
| total_water = wdf["total_kl"].sum() if "total_kl" in wdf else 0 | |
| peak_month = wdf.loc[wdf["total_kl"].idxmax(), "period"] if "total_kl" in wdf else "—" | |
| rain_pct = (wdf["rainwater_kl"].sum() / total_water * 100) if ("rainwater_kl" in wdf and total_water > 0) else 0 | |
| wk1.metric("Total Consumed", f"{total_water:,.0f} kL") | |
| wk2.metric("Peak Month", peak_month) | |
| wk3.metric("Rainwater %", f"{rain_pct:.1f}%") | |
| with st.expander("📋 Water Data Table"): | |
| st.dataframe(wdf, use_container_width=True, hide_index=True) | |
| else: | |
| st.info("No water data detected. Upload your SPJIMR Environmental Metrics XLSX.") | |
| # ── 3. WASTE ────────────────────────────────────────────────────────────── | |
| st.markdown("---") | |
| st.markdown("## ♻️ Waste Management") | |
| if has_waste_full: | |
| wst = st.session_state.waste_full.copy() | |
| periods = wst["period"].tolist() | |
| wst1, wst2 = st.columns(2, gap="large") | |
| with wst1: | |
| fig_wst = go.Figure() | |
| if "recovered_kg" in wst: fig_wst.add_trace(go.Bar(x=periods, y=wst["recovered_kg"], name="Recovered / Recycled (kg)", marker_color="#F67D31")) | |
| if "disposed_kg" in wst: fig_wst.add_trace(go.Bar(x=periods, y=wst["disposed_kg"], name="Disposed (kg)", marker_color="#E74C3C", opacity=0.75)) | |
| fig_wst.update_layout(**_PLOT_LAYOUT, barmode="group", height=320, | |
| title=dict(text="Waste Recovered vs Disposed (kg)", font=dict(color="#FFA066"))) | |
| fig_wst.update_yaxes(gridcolor="rgba(255,255,255,0.06)", title="kg") | |
| st.plotly_chart(fig_wst, use_container_width=True) | |
| with wst2: | |
| if "recovered_pct" in wst: | |
| fig_rp = go.Figure() | |
| fig_rp.add_trace(go.Scatter( | |
| x=periods, y=wst["recovered_pct"], | |
| mode="lines+markers+text", | |
| text=[f"{v:.0f}%" for v in wst["recovered_pct"]], | |
| textposition="top center", | |
| textfont=dict(size=9, color="#C4A4D4"), | |
| line=dict(color="#F67D31", width=2), | |
| fill="tozeroy", fillcolor="rgba(246,125,49,0.1)", | |
| name="Recovery %", | |
| )) | |
| fig_rp.add_hline(y=50, line_dash="dot", line_color="#FFA066", | |
| annotation_text="50% Target", annotation_font=dict(color="#FFA066")) | |
| fig_rp.update_layout(**_PLOT_LAYOUT, height=320, | |
| title=dict(text="Waste Recovery Rate (%)", font=dict(color="#FFA066"))) | |
| fig_rp.update_yaxes(gridcolor="rgba(255,255,255,0.06)", title="%", range=[0, 110]) | |
| st.plotly_chart(fig_rp, use_container_width=True) | |
| k1, k2, k3 = st.columns(3) | |
| total_wst = wst["total_kg"].sum() if "total_kg" in wst else 0 | |
| total_rec = wst["recovered_kg"].sum() if "recovered_kg" in wst else 0 | |
| latest_rec_pct = float(wst["recovered_pct"].iloc[-1]) if "recovered_pct" in wst else 0 | |
| k1.metric("Total Waste Generated", f"{total_wst:,.0f} kg") | |
| k2.metric("Total Waste Recovered", f"{total_rec:,.0f} kg") | |
| k3.metric("Latest Recovery Rate", f"{latest_rec_pct:.1f}%", | |
| delta=f"{latest_rec_pct - float(wst['recovered_pct'].iloc[0]):.1f}% since start" | |
| if "recovered_pct" in wst and len(wst) > 1 else None) | |
| with st.expander("📋 Waste Data Table"): | |
| st.dataframe(wst, use_container_width=True, hide_index=True) | |
| elif has_waste: | |
| wdf_ = st.session_state.waste_df.copy() | |
| fig = go.Figure() | |
| for col, color, name in [("wet_kg","#F67D31","Recovered (kg)"),("dry_kg","#E74C3C","Disposed (kg)")]: | |
| if col in wdf_.columns: | |
| fig.add_trace(go.Bar(x=wdf_["period"], y=wdf_[col], name=name, marker_color=color, opacity=0.85)) | |
| fig.update_layout(**_PLOT_LAYOUT, barmode="group", height=300) | |
| fig.update_yaxes(gridcolor="rgba(255,255,255,0.06)", title="kg") | |
| st.plotly_chart(fig, use_container_width=True) | |
| else: | |
| st.info("No waste data detected. Upload your SPJIMR Environmental Metrics XLSX.") | |
| # ── 4. SDG Alignment ────────────────────────────────────────────────────── | |
| st.markdown("---") | |
| st.markdown("## 🎯 SDG Alignment Snapshot") | |
| latest_renewable = float(st.session_state.energy_df["renewable_pct"].iloc[-1]) if has_energy else 0 | |
| latest_waste_rec = 0 | |
| if has_waste_full and "recovered_pct" in st.session_state.waste_full.columns: | |
| latest_waste_rec = float(st.session_state.waste_full["recovered_pct"].iloc[-1]) | |
| elif has_waste and "wet_kg" in st.session_state.waste_df.columns: | |
| wdf_ = st.session_state.waste_df | |
| if "dry_kg" in wdf_.columns: | |
| denom = wdf_["wet_kg"].iloc[-1] + wdf_["dry_kg"].iloc[-1] | |
| latest_waste_rec = float(wdf_["wet_kg"].iloc[-1] / denom * 100) if denom > 0 else 0 | |
| sdg_data = { | |
| "SDG 4 – Quality Education": 85, | |
| "SDG 6 – Clean Water & Sanitation": ( | |
| min(100, int(100 - (st.session_state.water_df["tanker_kl"].sum() / | |
| st.session_state.water_df["total_kl"].sum() * 100))) if has_water else 65 | |
| ), | |
| "SDG 7 – Affordable & Clean Energy": min(100, int(latest_renewable)), | |
| "SDG 11 – Sustainable Cities": 70, | |
| "SDG 12 – Responsible Consumption": min(100, int(latest_waste_rec)), | |
| "SDG 13 – Climate Action": 75, | |
| } | |
| fig_sdg = go.Figure(go.Bar( | |
| x=list(sdg_data.values()), y=list(sdg_data.keys()), orientation="h", | |
| marker=dict(color=list(sdg_data.values()), | |
| colorscale=[[0,"#E74C3C"],[0.5,"#F67D31"],[1,"#FFA066"]], | |
| showscale=False), | |
| text=[f"{v}%" for v in sdg_data.values()], textposition="auto", | |
| )) | |
| fig_sdg.update_layout( | |
| paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", | |
| font=dict(color="#C4A4D4", family="DM Sans", size=11), | |
| xaxis=dict(range=[0,110], gridcolor="rgba(255,255,255,0.06)", title="Progress %"), | |
| yaxis=dict(gridcolor="rgba(255,255,255,0.06)"), | |
| margin=dict(l=0, r=0, t=10, b=0), height=300, | |
| ) | |
| st.plotly_chart(fig_sdg, use_container_width=True) | |
| st.caption("SDG 6, 7, 12 auto-populated from uploaded data. SDG 4, 11, 13 are baseline estimates.") | |
| # ── 5. Predictive Analytics ─────────────────────────────────────────────── | |
| st.markdown("---") | |
| st.markdown("## 🔮 Predictive Analytics — Forecast") | |
| def _poly_forecast(series, periods): | |
| y = __import__("pandas").to_numeric(__import__("pandas").Series(series), errors="coerce").dropna().values.astype(float) | |
| if len(y) < 3: return None, None, None | |
| x = np.arange(len(y), dtype=float) | |
| xf = np.arange(len(y), len(y) + periods, dtype=float) | |
| c = np.polyfit(x, y, min(2, len(y)-1)) | |
| err = np.std(y - np.polyval(c, x)) * 1.96 | |
| fc = np.polyval(c, xf) | |
| return fc, fc - err, fc + err | |
| horizon = st.slider("Forecast horizon (months)", 3, 12, 6, key="fc_horizon") | |
| fl = [f"M+{i+1}" for i in range(horizon)] | |
| has_fc = False | |
| def _add_fc(fig, hist_x, hist_y, future_x, color, name): | |
| r, g, b = int(color[1:3],16), int(color[3:5],16), int(color[5:7],16) | |
| fc, lo, hi = _poly_forecast(hist_y, len(future_x)) | |
| if fc is None: return | |
| fig.add_trace(go.Scatter(x=future_x, y=np.maximum(fc, 0), name=f"{name} (Forecast)", | |
| mode="lines+markers", line=dict(color=color, width=2, dash="dash"), | |
| marker=dict(size=7, symbol="diamond"))) | |
| fig.add_trace(go.Scatter( | |
| x=future_x + future_x[::-1], | |
| y=list(np.maximum(hi,0)) + list(np.maximum(lo,0))[::-1], | |
| fill="toself", fillcolor=f"rgba({r},{g},{b},0.1)", | |
| line=dict(color="rgba(0,0,0,0)"), showlegend=False)) | |
| if has_energy: | |
| has_fc = True | |
| edf_f = st.session_state.energy_df.dropna(subset=["renewable_pct"]).copy() | |
| fig_ef = go.Figure() | |
| fig_ef.add_trace(go.Scatter(x=list(edf_f["period"]), y=edf_f["renewable_pct"], | |
| name="Renewable % (Actual)", mode="lines+markers", | |
| line=dict(color="#F67D31", width=2), marker=dict(size=5))) | |
| _add_fc(fig_ef, list(edf_f["period"]), edf_f["renewable_pct"].values, fl, "#F67D31", "Renewable %") | |
| _hline(fig_ef) | |
| fig_ef.update_layout(**_PLOT_LAYOUT, height=320, | |
| title=dict(text=f"Renewable Energy % Forecast (next {horizon} months)", font=dict(color="#FFA066"))) | |
| fig_ef.update_yaxes(gridcolor="rgba(255,255,255,0.06)", title="%", range=[0, 115]) | |
| st.plotly_chart(fig_ef, use_container_width=True) | |
| if has_waste_full: | |
| has_fc = True | |
| wst_f = st.session_state.waste_full.copy() | |
| fig_wf = go.Figure() | |
| for col, color, name in [("recovered_kg","#F67D31","Recovered"),("disposed_kg","#E74C3C","Disposed")]: | |
| if col in wst_f.columns: | |
| fig_wf.add_trace(go.Scatter(x=list(wst_f["period"]), y=wst_f[col], | |
| name=f"{name} (Actual)", mode="lines+markers", | |
| line=dict(color=color, width=2), marker=dict(size=5))) | |
| _add_fc(fig_wf, list(wst_f["period"]), wst_f[col].values, fl, color, name) | |
| fig_wf.update_layout(**_PLOT_LAYOUT, height=320, | |
| title=dict(text=f"Waste Forecast (next {horizon} months)", font=dict(color="#FFA066"))) | |
| fig_wf.update_yaxes(gridcolor="rgba(255,255,255,0.06)", title="kg") | |
| st.plotly_chart(fig_wf, use_container_width=True) | |
| if has_water: | |
| has_fc = True | |
| wtr_f = st.session_state.water_df.copy() | |
| if "total_kl" in wtr_f.columns: | |
| fig_wtr = go.Figure() | |
| fig_wtr.add_trace(go.Scatter(x=list(wtr_f["period"]), y=wtr_f["total_kl"], | |
| name="Total Water (Actual)", mode="lines+markers", | |
| line=dict(color="#3498DB", width=2), marker=dict(size=5))) | |
| _add_fc(fig_wtr, list(wtr_f["period"]), wtr_f["total_kl"].values, fl, "#3498DB", "Total Water") | |
| fig_wtr.update_layout(**_PLOT_LAYOUT, height=300, | |
| title=dict(text=f"Water Consumption Forecast (next {horizon} months)", font=dict(color="#FFA066"))) | |
| fig_wtr.update_yaxes(gridcolor="rgba(255,255,255,0.06)", title="kL") | |
| st.plotly_chart(fig_wtr, use_container_width=True) | |
| if not has_fc: | |
| st.info("Upload the SPJIMR Environmental Metrics XLSX to enable forecasts.") | |
| st.caption("**Methodology:** Polynomial regression (degree ≤ 2) · 95% CI = ±1.96σ of historical residuals.") | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # PAGE: AI Consultant | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def page_consultant(): | |
| st.markdown("# 🤖 AI ESG Consultant") | |
| st.markdown("Ask strategic sustainability questions. The AI retrieves relevant data from your uploaded documents and synthesises expert insights.") | |
| if not st.session_state.hf_token: | |
| st.error("🔑 Please enter your Hugging Face API token in the sidebar.") | |
| return | |
| consultant = _get_consultant() | |
| if not consultant.is_ready: | |
| st.warning("⚠️ Knowledge base is empty. Head to **📤 Data Ingestion** to upload documents first.") | |
| return | |
| preset_questions = [ | |
| "Custom question…", | |
| "What are the top 3 waste reduction opportunities for SPJIMR campus?", | |
| "How does our renewable energy progress compare to peer institutions?", | |
| "Which SDGs are we best and worst aligned with, and why?", | |
| "What initiatives should we launch to achieve net-zero by 2030?", | |
| "Summarise our ESG performance highlights for an annual report.", | |
| "What are the key risks and gaps in our current sustainability strategy?", | |
| ] | |
| col1, col2 = st.columns([2, 1], gap="large") | |
| with col1: | |
| selected = st.selectbox("💡 Quick Insights", preset_questions) | |
| question = st.text_area("Your Strategic Question", | |
| value="" if selected == "Custom question…" else selected, | |
| height=110, | |
| placeholder="e.g. What should be our top ESG priority for the next academic year?") | |
| with col2: | |
| st.markdown("### ⚙️ Query Settings") | |
| top_k = st.slider("Chunks to retrieve (top-k)", 2, 10, 5) | |
| max_tokens = st.slider("Max response tokens", 256, 2048, 1024, step=128) | |
| temperature = st.slider("Creativity (temperature)", 0.1, 1.0, 0.4, step=0.05) | |
| if st.button("🔍 Get Strategic Insight", use_container_width=True): | |
| if not question.strip(): | |
| st.warning("Please enter a question.") | |
| return | |
| with st.spinner("🧠 Consulting AI — retrieving context and generating insights…"): | |
| response = consultant.query(question, top_k=top_k, max_tokens=max_tokens, temperature=temperature) | |
| st.markdown("---") | |
| st.markdown("## 📋 Strategic Analysis") | |
| st.markdown(f'<div class="answer-box">{response["answer"]}</div>', unsafe_allow_html=True) | |
| st.markdown("---") | |
| with st.expander(f"📚 Retrieved Context Chunks ({response['chunks_used']} used)"): | |
| for i, chunk in enumerate(response["sources"], 1): | |
| st.markdown(f"**Chunk {i}:**") | |
| st.text(chunk) | |
| st.divider() | |
| st.markdown("---") | |
| st.caption("💡 **Tip:** Upload multiple document types for richer, cross-referenced insights.") | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # PAGE: Creative Studio | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| def page_creative_studio(): | |
| st.markdown("# 🎨 Marketing Creative Studio") | |
| st.markdown("Multi-modal ESG content generation — posters, videos, social copy, and audio narration.") | |
| if not st.session_state.hf_token: | |
| st.error("🔑 Please enter your Hugging Face API token in the sidebar.") | |
| return | |
| consultant = _get_consultant() | |
| # Model badge strip | |
| badges = [ | |
| ("#F67D31", "✍️ Creative Text → Phi-3.5-mini"), | |
| ("#500073", "🖼 Image/Poster → FLUX.1-Schnell"), | |
| ("#E74C3C", "🎬 Video → ModelScope text-to-video"), | |
| ("#3498DB", "🔊 Audio → SpeechT5 (local) + HF API fallback"), | |
| ] | |
| badge_html = '<div style="display:flex;gap:0.6rem;flex-wrap:wrap;margin-bottom:1.2rem;">' | |
| for color, label in badges: | |
| badge_html += ( | |
| f'<div style="background:rgba({int(color[1:3],16)},{int(color[3:5],16)},{int(color[5:7],16)},0.12);' | |
| f'border:1px solid rgba({int(color[1:3],16)},{int(color[3:5],16)},{int(color[5:7],16)},0.35);' | |
| f'border-radius:8px;padding:0.4rem 0.9rem;font-size:0.72rem;color:{color};">{label}</div>' | |
| ) | |
| badge_html += '</div>' | |
| st.markdown(badge_html, unsafe_allow_html=True) | |
| tab_poster, tab_video, tab_social, tab_audio = st.tabs( | |
| ["🖼 Poster / Image", "🎬 Video Brief", "📱 Social Media", "🔊 Audio Narration"] | |
| ) | |
| with tab_poster: | |
| st.markdown("### 🖼 AI Poster Generator") | |
| st.caption("Phi-3.5 writes the optimised prompt → FLUX.1-Schnell generates the actual image") | |
| col_a, col_b = st.columns([3, 1], gap="large") | |
| with col_a: | |
| poster_brief = st.text_area("Poster Brief", | |
| value="Create a sustainability poster celebrating SPJIMR's zero-waste campus achievement.", | |
| height=100, key="brief_poster") | |
| with col_b: | |
| p_style = st.selectbox("Visual Style", ["Photorealistic","Cinematic","Minimalist","Bold Graphic","Watercolour"], key="p_style") | |
| p_size = st.selectbox("Aspect Ratio", ["1024×1024 (Square)","1024×576 (Landscape)","576×1024 (Portrait)"], key="p_size") | |
| p_mood = st.selectbox("Mood", ["Optimistic & Bright","Serene & Natural","Bold & Impactful","Warm & Human"], key="p_mood") | |
| size_map = {"1024×1024 (Square)":(1024,1024),"1024×576 (Landscape)":(1024,576),"576×1024 (Portrait)":(576,1024)} | |
| if st.button("✍️ Step 1 — Generate Optimised Prompt", key="gen_poster_prompt", use_container_width=True): | |
| if poster_brief.strip(): | |
| enriched = f"{poster_brief}\nStyle: {p_style} | Mood: {p_mood}\nFor SPJIMR Mumbai sustainability campaign." | |
| with st.spinner("✍️ Phi-3.5 crafting the perfect image prompt…"): | |
| result = consultant.creative_text(enriched, mode="poster", top_k=4) | |
| st.session_state["poster_prompt_text"] = result["answer"] | |
| st.success("✅ Prompt ready — review and edit below, then generate the image.") | |
| if "poster_prompt_text" in st.session_state: | |
| edited_prompt = st.text_area("📝 Optimised Prompt (edit before generating)", | |
| value=st.session_state["poster_prompt_text"], height=150, key="edited_poster_prompt") | |
| st.code(edited_prompt, language=None) | |
| if st.button("🖼 Step 2 — Generate Image (FLUX.1-Schnell)", key="gen_img", use_container_width=True): | |
| w, h = size_map[p_size] | |
| with st.spinner("🎨 FLUX.1-Schnell generating your poster… (15–30s)"): | |
| img_bytes = consultant.create_image(edited_prompt, width=w, height=h) | |
| if img_bytes: | |
| st.image(img_bytes, caption="Generated by FLUX.1-Schnell · SPJIMR ESG Campaign") | |
| st.download_button("⬇️ Download Poster (PNG)", data=img_bytes, | |
| file_name="spjimr_esg_poster.png", mime="image/png", use_container_width=True) | |
| else: | |
| st.error("❌ Image generation failed. Try copying the prompt into Midjourney or DALL-E 3.") | |
| with tab_video: | |
| st.markdown("### 🎬 AI Video Generator") | |
| col_a, col_b = st.columns([3, 1], gap="large") | |
| with col_a: | |
| video_brief = st.text_area("Video Idea", | |
| value="A cinematic clip of SPJIMR campus — solar panels, composting stations, students and sustainability.", | |
| height=110, key="brief_video") | |
| with col_b: | |
| v_style = st.selectbox("Style", ["Documentary","Cinematic","Social Reel","Timelapse"], key="v_style") | |
| v_tone = st.selectbox("Tone", ["Inspirational","Factual","Emotional","Energetic"], key="v_tone") | |
| v_frames = st.select_slider("Frames", options=[8, 16, 24], value=16, key="v_frames") | |
| if st.button("🎬 Write Brief & Generate Video", key="gen_video_full", use_container_width=True): | |
| if video_brief.strip(): | |
| enriched = f"{video_brief}\nStyle: {v_style} | Tone: {v_tone}\nFor SPJIMR sustainability campaign." | |
| with st.spinner("✍️ Step 1/3 — Phi-3.5 writing video brief…"): | |
| result = consultant.creative_text(enriched, mode="video", top_k=4) | |
| full_brief = result["answer"] | |
| st.markdown(f'<div class="prompt-card">{full_brief}</div>', unsafe_allow_html=True) | |
| with st.spinner("🔍 Step 2/3 — Condensing brief…"): | |
| condensed = consultant._condense_for_video(full_brief) | |
| st.info(f"🎯 **Video model prompt:** {condensed}") | |
| prog = st.progress(0, text="⏳ Step 3/3 — Connecting to video model…") | |
| status = st.empty() | |
| def _upd(msg): | |
| status.info(f"🎬 {msg}") | |
| if "Attempt 1" in msg: prog.progress(20) | |
| elif "Attempt 2" in msg: prog.progress(45) | |
| elif "Attempt 3" in msg: prog.progress(65) | |
| video_bytes = consultant.create_video(condensed, num_frames=v_frames, status_cb=_upd) | |
| prog.progress(100) | |
| if video_bytes: | |
| status.success(f"✅ Done! ({len(video_bytes):,} bytes)") | |
| import tempfile, os as _os | |
| tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".mp4") | |
| tmp.write(video_bytes); tmp.close() | |
| st.video(tmp.name) | |
| st.download_button("⬇️ Download Video (MP4)", data=video_bytes, | |
| file_name="spjimr_esg_video.mp4", mime="video/mp4", use_container_width=True) | |
| _os.unlink(tmp.name) | |
| else: | |
| status.error("❌ HF free-tier video generation unavailable.") | |
| st.code(condensed, language=None) | |
| with tab_social: | |
| st.markdown("### 📱 Social Media Content Generator") | |
| col_a, col_b = st.columns([3, 1], gap="large") | |
| with col_a: | |
| social_brief = st.text_area("Social Brief", | |
| value="Celebrate SPJIMR reaching 70% renewable energy — inspire peer institutions.", | |
| height=100, key="brief_social") | |
| with col_b: | |
| s_platform = st.selectbox("Platform", ["LinkedIn","Instagram","Twitter/X","All Platforms"], key="s_platform") | |
| s_format = st.selectbox("Format", ["Static Post","Carousel","Reel/Short Video","Story"], key="s_format") | |
| if st.button("📱 Generate Social Content", key="gen_social", use_container_width=True): | |
| if social_brief.strip(): | |
| enriched = f"{social_brief}\nPlatform: {s_platform} | Format: {s_format}" | |
| with st.spinner("📱 Phi-3.5 writing your social content…"): | |
| result = consultant.creative_text(enriched, mode="social", top_k=4) | |
| st.markdown(f'<div class="prompt-card">{result["answer"]}</div>', unsafe_allow_html=True) | |
| st.code(result["answer"], language=None) | |
| with tab_audio: | |
| st.markdown("### 🔊 Audio Narration Generator") | |
| col_a, col_b = st.columns([3, 1], gap="large") | |
| with col_a: | |
| audio_brief = st.text_area("Narration Brief", | |
| value="A 60-second podcast intro about SPJIMR's ESG progress and sustainability milestones.", | |
| height=100, key="brief_audio") | |
| with col_b: | |
| a_tone = st.selectbox("Script Tone", ["Authoritative & Warm","Energetic & Inspiring","Calm & Reflective","Conversational"], key="a_tone") | |
| a_dur = st.selectbox("Duration", ["30 seconds (~75 words)","60 seconds (~150 words)","90 seconds (~225 words)"], key="a_dur") | |
| a_mode = st.radio("Audio Mode", ["🎙️ Single Speaker","🎙️🎙️ Podcast (HOST / GUEST)"], key="a_mode") | |
| if st.button("✍️ Step 1 — Write Narration Script", key="gen_script", use_container_width=True): | |
| if audio_brief.strip(): | |
| podcast_hint = ("\nWrite as podcast dialogue. [HOST] and [GUEST] tags per line." if "Podcast" in a_mode else "") | |
| enriched = f"{audio_brief}\nTone: {a_tone} | Duration: {a_dur}{podcast_hint}" | |
| with st.spinner("✍️ Phi-3.5 writing your narration script…"): | |
| result = consultant.creative_text(enriched, mode="audio_script", top_k=4) | |
| st.session_state["audio_script_text"] = result["answer"] | |
| st.success("✅ Script ready.") | |
| if "audio_script_text" in st.session_state: | |
| edited_script = st.text_area("📝 Narration Script", | |
| value=st.session_state["audio_script_text"], height=180, key="edited_script") | |
| if st.button("🔊 Step 2 — Generate Audio", key="gen_audio", use_container_width=True): | |
| with st.spinner("🔊 Generating audio narration…"): | |
| try: | |
| audio_bytes = (consultant.create_podcast_audio(edited_script) | |
| if "Podcast" in a_mode | |
| else consultant.create_audio(edited_script)) | |
| st.audio(audio_bytes, format="audio/wav") | |
| st.download_button("⬇️ Download Narration (WAV)", data=audio_bytes, | |
| file_name="spjimr_esg_narration.wav", mime="audio/wav", use_container_width=True) | |
| except RuntimeError as e: | |
| st.error(f"❌ Audio generation failed: {e}") | |
| st.markdown("---") | |
| st.markdown("### 🛠 Model Summary") | |
| model_data = { | |
| "✍️ Creative Writing": ("Phi-3.5-mini", "Microsoft", "Chat / Creative"), | |
| "🖼 Image Generation": ("FLUX.1-Schnell", "Black Forest Labs", "Text→Image"), | |
| "🎬 Video Generation": ("text-to-video", "ModelScope / DAMO", "Text→Video"), | |
| "🔊 Audio / TTS": ("SpeechT5 + HiFi-GAN","Microsoft", "Text→Speech"), | |
| "🧠 Strategy / RAG": ("Qwen2.5-7B", "Alibaba / Qwen", "Chat / Reasoning"), | |
| } | |
| cols = st.columns(5) | |
| for col, (task, (model, org, task_type)) in zip(cols, model_data.items()): | |
| col.markdown( | |
| f'<div class="esg-card"><h4>{task}</h4>' | |
| f'<div class="big-num" style="font-size:0.92rem;color:#C4A4D4;">{model}</div>' | |
| f'<div class="sub">{org} · {task_type}</div></div>', | |
| unsafe_allow_html=True, | |
| ) | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| # Router — CSS injected conditionally based on login state | |
| # ═══════════════════════════════════════════════════════════════════════════════ | |
| if not st.session_state["logged_in"]: | |
| # Light theme for login page — do NOT inject SPJIMR_CSS here | |
| render_login() | |
| else: | |
| # Dark theme for the main app — inject SPJIMR_CSS only when logged in | |
| st.markdown(SPJIMR_CSS, unsafe_allow_html=True) | |
| page = render_sidebar() | |
| render_hero() | |
| if page == "📤 Data Ingestion": page_ingestion() | |
| elif page == "📊 ESG Dashboard": page_dashboard() | |
| elif page == "🤖 AI Consultant": page_consultant() | |
| elif page == "🎨 Creative Studio": page_creative_studio() | |
| elif page == "📝 Data Entry": render_data_entry() | |
| elif page == "♻️ Waste Analytics": render_waste_analytics() | |
| elif page == "🏆 Gamification": render_gamification() | |
| elif page == "🏫 Peer Benchmarking": render_peer_benchmarking() |