""" 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'
Master sheet completeness
' f'Information expected in the report ' 'but missing from this sheet
', 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('Data quality notes
', 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") # --------------------------------------------------------------------------- # # Styling # --------------------------------------------------------------------------- # st.markdown(""" """, unsafe_allow_html=True) # --------------------------------------------------------------------------- # # Hero # --------------------------------------------------------------------------- # st.markdown("""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.
1 · Master sheet
', unsafe_allow_html=True) st.markdown('Upload the community PUE Master Sheet ' '(.xlsx). Everything in the report is extracted from it.
', unsafe_allow_html=True) uploaded = st.file_uploader("Master sheet", type=["xlsx", "xlsm"], label_visibility="collapsed") # --------------------------------------------------------------------------- # # 2 — Report details # --------------------------------------------------------------------------- # with st.container(border=True): st.markdown('2 · Report details
', unsafe_allow_html=True) st.markdown('Shown on the title page and in the ' 'introduction.
', 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") # --------------------------------------------------------------------------- # # 3 — AI narratives (optional) # --------------------------------------------------------------------------- # 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('3 · AI narratives ' '(optional)
', unsafe_allow_html=True) st.markdown('Let Gemini polish the three prose sections. ' 'Without a key, they use a clear deterministic fallback — everything ' 'else is identical.
', 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.") with st.container(border=True): st.markdown('4 · Logos ' '(optional)
', unsafe_allow_html=True) st.markdown('Add a developer logo (placed on the ' 'left of the page header) and/or a DREEF logo (placed on the ' 'right). PNG, JPG, or JPEG. Leave either blank to omit that ' 'logo.
', unsafe_allow_html=True) _logo_col_a, _logo_col_b = st.columns(2) with _logo_col_a: developer_logo = st.file_uploader( "Developer logo", type=["png", "jpg", "jpeg"], key="developer_logo", label_visibility="visible") with _logo_col_b: dreef_logo = st.file_uploader( "DREEF logo", type=["png", "jpg", "jpeg"], key="dreef_logo", label_visibility="visible") 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.") # --------------------------------------------------------------------------- # # Evaluate — completeness score + every expected-but-missing data point # --------------------------------------------------------------------------- # 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}") # --------------------------------------------------------------------------- # # Generate # --------------------------------------------------------------------------- # 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 # Persist uploaded logos to temp files so python-docx (which reads # image files from disk) can pick them up. Paths get cleaned up in # the finally block below alongside the workbook. def _persist_logo(upload) -> str | None: if upload is None: return None suffix = os.path.splitext(upload.name)[1] or ".png" with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as t: t.write(upload.getvalue()) return t.name dev_logo_path = _persist_logo(developer_logo) dreef_logo_path = _persist_logo(dreef_logo) 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, developer_logo_path=dev_logo_path, dreef_logo_path=dreef_logo_path) with open(out_path, "rb") as fh: docx_bytes = fh.read() finally: for p in (xlsx_path, dev_logo_path, dreef_logo_path): if p: try: os.unlink(p) 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('Figures are recomputed deterministically from the ' 'master sheet · AI assists only with prose
', unsafe_allow_html=True)