PUE_Report_Generator_AI / streamlit_app.py
Israelbliz's picture
Upload streamlit_app.py
07dae23 verified
Raw
History Blame Contribute Delete
11.5 kB
"""
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&nbsp;&nbsp;&nbsp;&nbsp;"
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")
# --------------------------------------------------------------------------- #
# Styling
# --------------------------------------------------------------------------- #
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)
# --------------------------------------------------------------------------- #
# Hero
# --------------------------------------------------------------------------- #
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)
# --------------------------------------------------------------------------- #
# 1 — Upload
# --------------------------------------------------------------------------- #
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")
# --------------------------------------------------------------------------- #
# 2 — Report details
# --------------------------------------------------------------------------- #
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")
# --------------------------------------------------------------------------- #
# 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('<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.")
# --------------------------------------------------------------------------- #
# 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
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)