| """ |
| Streamlit front-end for the DREEF PUE report generator. |
| |
| All inputs live on the main page (no sidebar). Upload a community master-sheet |
| workbook, evaluate its completeness, set the report details, and download a |
| template-faithful Word report. Every figure is recomputed from the sheet — the |
| optional AI step only writes prose. |
| |
| Run locally: streamlit run app.py |
| """ |
| from __future__ import annotations |
|
|
| import os |
| import tempfile |
|
|
| import streamlit as st |
|
|
| from pue_report_agent import extract_master_sheet, recompute, validate |
| from pue_report_agent.completeness import score_completion |
|
|
|
|
| @st.cache_data(show_spinner=False) |
| def _extract_validated(file_bytes: bytes): |
| """Extract + recompute + validate an uploaded workbook (cached by content).""" |
| import os as _os |
| import tempfile as _tf |
| with _tf.NamedTemporaryFile(suffix=".xlsx", delete=False) as t: |
| t.write(file_bytes) |
| path = t.name |
| try: |
| return validate(recompute(extract_master_sheet(path))) |
| finally: |
| try: |
| _os.unlink(path) |
| except OSError: |
| pass |
|
|
|
|
| def _render_score(sc): |
| colr = "#2E7D6B" if sc.overall >= 85 else ("#B7791F" if sc.overall >= 60 else "#C0392B") |
| st.markdown( |
| f'<p class="section-title">Master sheet completeness</p>' |
| f'<div style="display:flex;align-items:baseline;gap:.5rem;">' |
| f'<span style="font-size:2.4rem;font-weight:800;color:{colr};">{sc.overall}</span>' |
| f'<span style="color:#98a2b3;font-weight:600;">/ 100</span></div>', |
| unsafe_allow_html=True) |
| st.progress(sc.overall / 100) |
|
|
|
|
| def _render_evaluation(data): |
| sc = score_completion(data) |
| _render_score(sc) |
| gaps = [(d.name, d.missing) for d in sc.domains if d.missing] |
| st.write("") |
| if gaps: |
| st.markdown('<p class="section-title">Information expected in the report ' |
| 'but missing from this sheet</p>', unsafe_allow_html=True) |
| for name, missing in gaps: |
| st.markdown(f"🔴 **{name}** \n " |
| f"{', '.join(missing)}", unsafe_allow_html=True) |
| else: |
| st.success("All expected data points are present in this sheet.") |
| if data.warnings: |
| st.write("") |
| st.markdown('<p class="section-title">Data quality notes</p>', |
| unsafe_allow_html=True) |
| for w in data.warnings: |
| st.markdown(f"- {w}") |
|
|
| st.set_page_config(page_title="PUE Report Generator", page_icon="⚡", |
| layout="centered") |
|
|
| |
| |
| |
| st.markdown(""" |
| <style> |
| @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap'); |
| |
| html, body, [class*="css"] { font-family: 'Inter', sans-serif; } |
| #MainMenu, footer, header { visibility: hidden; } |
| .block-container { padding-top: 1.6rem; padding-bottom: 3rem; max-width: 840px; } |
| |
| /* Hero banner */ |
| .hero { |
| background: linear-gradient(135deg, #1F4E79 0%, #2E7D6B 100%); |
| border-radius: 20px; padding: 2.4rem 2.2rem; color: #fff; margin-bottom: 1.8rem; |
| box-shadow: 0 14px 36px rgba(31,78,121,0.28); |
| } |
| .hero h1 { font-size: 2.05rem; font-weight: 800; margin: 0 0 .45rem 0; |
| color: #fff; letter-spacing: -0.5px; } |
| .hero p { margin: 0; opacity: .94; font-size: 1.04rem; line-height: 1.5; } |
| .badges { margin-top: 1.1rem; display: flex; flex-wrap: wrap; gap: .5rem; } |
| .badge { background: rgba(255,255,255,.16); border: 1px solid rgba(255,255,255,.25); |
| padding: .28rem .7rem; border-radius: 999px; font-size: .78rem; font-weight: 500; } |
| |
| /* Section titles inside cards */ |
| .section-title { font-weight: 700; color: #1F4E79; font-size: 1.06rem; |
| margin: 0 0 .15rem 0; } |
| .section-sub { color: #667085; font-size: .85rem; margin: 0 0 .7rem 0; } |
| |
| /* Buttons */ |
| .stButton > button, .stDownloadButton > button { |
| background: #1F4E79 !important; color: #fff !important; border: none !important; |
| border-radius: 11px !important; padding: .7rem 1.2rem !important; |
| font-weight: 600 !important; font-size: 1.02rem !important; |
| transition: background .15s ease, transform .05s ease; |
| } |
| .stButton > button:hover, .stDownloadButton > button:hover { background: #163a5c !important; } |
| .stButton > button:active { transform: translateY(1px); } |
| .stDownloadButton > button { background: #2E7D6B !important; } |
| .stDownloadButton > button:hover { background: #24624f !important; } |
| |
| /* Metric cards */ |
| div[data-testid="stMetric"] { |
| background: #f6f8fc; border: 1px solid #e6e9ef; border-radius: 12px; |
| padding: .8rem 1rem; |
| } |
| div[data-testid="stMetricValue"] { color: #1F4E79; font-weight: 700; } |
| |
| /* Footer note */ |
| .foot { text-align: center; color: #98a2b3; font-size: .8rem; margin-top: 1.6rem; } |
| </style> |
| """, unsafe_allow_html=True) |
|
|
| |
| |
| |
| st.markdown(""" |
| <div class="hero"> |
| <h1>⚡ PUE Report Generator</h1> |
| <p>Turn a community PUE master-sheet workbook into a polished, template-faithful |
| Word report in seconds. Every number is recomputed straight from the sheet — |
| the AI only ever writes prose, never figures.</p> |
| <div class="badges"> |
| <span class="badge">Deterministic figures</span> |
| <span class="badge">Template-faithful .docx</span> |
| <span class="badge">Built-in quality checks</span> |
| </div> |
| </div> |
| """, unsafe_allow_html=True) |
|
|
| |
| |
| |
| with st.container(border=True): |
| st.markdown('<p class="section-title">1 · Master sheet</p>', unsafe_allow_html=True) |
| st.markdown('<p class="section-sub">Upload the community PUE Master Sheet ' |
| '(.xlsx). Everything in the report is extracted from it.</p>', |
| unsafe_allow_html=True) |
| uploaded = st.file_uploader("Master sheet", type=["xlsx", "xlsm"], |
| label_visibility="collapsed") |
|
|
| |
| |
| |
| with st.container(border=True): |
| st.markdown('<p class="section-title">2 · Report details</p>', unsafe_allow_html=True) |
| st.markdown('<p class="section-sub">Shown on the title page and in the ' |
| 'introduction.</p>', unsafe_allow_html=True) |
| c1, c2 = st.columns(2) |
| developer = c1.text_input("Mini-grid developer", |
| value="", placeholder="e.g. Green Edge Consortium") |
| report_date = c2.text_input("Report date", |
| value="", placeholder="e.g. December 2025") |
|
|
| |
| |
| |
| def _has_api_key() -> bool: |
| if os.environ.get("GOOGLE_API_KEY"): |
| return True |
| try: |
| return bool(st.secrets.get("GOOGLE_API_KEY")) |
| except Exception: |
| return False |
|
|
| with st.container(border=True): |
| st.markdown('<p class="section-title">3 · AI narratives ' |
| '<span style="font-weight:500;color:#98a2b3;">(optional)</span></p>', |
| unsafe_allow_html=True) |
| st.markdown('<p class="section-sub">Let Gemini polish the three prose sections. ' |
| 'Without a key, they use a clear deterministic fallback — everything ' |
| 'else is identical.</p>', unsafe_allow_html=True) |
| has_key = _has_api_key() |
| want_narratives = st.toggle("Generate AI prose sections", value=False) |
| if want_narratives and not has_key: |
| st.warning("No GOOGLE_API_KEY found — narratives will use the fallback.") |
|
|
| st.write("") |
| evaluate = st.button("🔍 Evaluate master sheet", use_container_width=True, |
| disabled=uploaded is None) |
| go = st.button("⚡ Generate report", use_container_width=True, type="primary", |
| disabled=uploaded is None) |
| if uploaded is None: |
| st.caption("Upload a master sheet above to evaluate or generate.") |
|
|
| |
| |
| |
| if evaluate and uploaded is not None: |
| with st.container(border=True): |
| try: |
| _data_eval = _extract_validated(uploaded.getvalue()) |
| _render_evaluation(_data_eval) |
| except Exception as e: |
| st.error(f"Could not read this workbook: {e}") |
|
|
| |
| |
| |
| if go and uploaded is not None: |
| with st.spinner("Extracting, recomputing, validating, and rendering…"): |
| with tempfile.NamedTemporaryFile(suffix=".xlsx", delete=False) as tmp: |
| tmp.write(uploaded.getvalue()) |
| xlsx_path = tmp.name |
| try: |
| data = extract_master_sheet(xlsx_path) |
| data = recompute(data) |
| data = validate(data) |
|
|
| note = None |
| if want_narratives: |
| from pue_report_agent import generate_narratives |
| if generate_narratives is None or not has_key: |
| note = "Prose sections used the deterministic fallback." |
| else: |
| data = generate_narratives(data) |
|
|
| from pue_report_agent.render import render |
| out_path = os.path.join(tempfile.gettempdir(), "PUE_Report.docx") |
| render(data, out_path, developer=developer or "the mini-grid developer", |
| report_date=report_date) |
| with open(out_path, "rb") as fh: |
| docx_bytes = fh.read() |
| finally: |
| try: |
| os.unlink(xlsx_path) |
| except OSError: |
| pass |
|
|
| with st.container(border=True): |
| st.success(f"Report ready for **{data.community.name or 'the community'}**.") |
| if note: |
| st.info(note) |
| fname = (data.community.name or "Community").replace(" ", "_") + "_PUE_Report.docx" |
| st.download_button("⬇ Download Word report", data=docx_bytes, file_name=fname, |
| use_container_width=True, |
| mime="application/vnd.openxmlformats-officedocument." |
| "wordprocessingml.document") |
| st.write("") |
| m1, m2, m3 = st.columns(3) |
| m1.metric("Respondents", f"{data.interviews.total:,}") |
| m2.metric("Machines", len(data.processors.machinery)) |
| m3.metric("Equipment cost (₦)", f"{(data.budget.grand_total or 0):,.0f}") |
|
|
| if data.warnings: |
| with st.expander(f"⚠️ {len(data.warnings)} item(s) to review"): |
| for w in data.warnings: |
| st.markdown(f"- {w}") |
| else: |
| st.caption("✅ No validation warnings.") |
|
|
| st.markdown('<p class="foot">Figures are recomputed deterministically from the ' |
| 'master sheet · AI assists only with prose</p>', unsafe_allow_html=True) |
|
|